Merge pull request #270 from outis1one/claude/vps-capacity-assessment-r57vw3
Claude/vps capacity assessment r57vw3
This commit is contained in:
@@ -596,6 +596,116 @@ sequence (a fake `docker`/`ss` shim standing in for a live daemon is fine)
|
||||
and confirm the second one's directory, container names, and ports are
|
||||
actually distinct before trusting the logic.
|
||||
|
||||
## Port collision avoidance
|
||||
|
||||
With 70+ services in this repo, several ship the same default port —
|
||||
`emby` and `jellyfin` both default to 8096, `changedetection` and `frigate`
|
||||
both default to 5000, `arm` and `nextcloud` both default to 8080. Nothing
|
||||
enforced those defaults were actually free on the host: whichever service
|
||||
started its container second would fail to bind ("port is already
|
||||
allocated") instead of landing on the next free port. Confirmed live:
|
||||
installing `jellyfin` after `emby` (or vice versa) writes a
|
||||
`docker-compose.yml` claiming a port the other service's container already
|
||||
holds, and it only fails at `docker compose up` time — not at install time,
|
||||
and not with any warning from the installer itself.
|
||||
|
||||
**Every service that publishes a fixed host port must scan for a free one
|
||||
before writing `docker-compose.yml` — on every install, not only when
|
||||
adding an explicit additional instance of itself.** Two shared helpers in
|
||||
`lib/common.sh` do the work:
|
||||
|
||||
```bash
|
||||
port_in_use PORT [PROTO] # true if something's already listening; PROTO defaults to tcp, pass "udp" for UDP-only ports
|
||||
find_free_port VARNAME START [PROTO] # scans upward from START, writes the free port back into VARNAME
|
||||
```
|
||||
|
||||
Single-port services just call `find_free_port`:
|
||||
|
||||
```bash
|
||||
local WEB_PORT="8080"
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
...
|
||||
- "${WEB_PORT}:8080" # host side scanned; container-internal side stays literal
|
||||
```
|
||||
|
||||
Services with multiple ports that must move together (a web port + an
|
||||
agent/RTSP/MQTT port, etc.) loop over `port_in_use` directly instead, the
|
||||
same pattern `services/meshcentral.sh` and `services/unifi.sh` use:
|
||||
|
||||
```bash
|
||||
while port_in_use "$WEB_PORT" || port_in_use "$AGENT_PORT"; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
AGENT_PORT=$((AGENT_PORT + 1))
|
||||
done
|
||||
```
|
||||
|
||||
On a normal single-install host this is a silent no-op — the default port
|
||||
is free, so the variable comes back unchanged and nothing about the
|
||||
install looks any different. It only changes behavior when something else
|
||||
already holds the port, which is exactly the case that used to fail at
|
||||
container-startup instead of being handled at install time.
|
||||
|
||||
**Standalone-mode stub.** Every service also carries a standalone
|
||||
bootstrap fallback (`if [[ -f "$_COMMON" ]]; then source it; else <stubs>
|
||||
fi`, for `sudo bash services/<name>.sh` with no sibling files sourced) that
|
||||
duplicates the helpers it needs. `port_in_use`/`find_free_port` get the
|
||||
same treatment — copy the same two function bodies into that `else` block,
|
||||
matching how `log_info`, `prompt_text`, `configure_caddy_for_service`, etc.
|
||||
are already duplicated there.
|
||||
|
||||
**Only the host side of a `"HOST:CONTAINER"` port mapping changes.** The
|
||||
container-internal port is fixed by the application itself and stays a
|
||||
literal number; only the host-published side becomes `${WEB_PORT}` (or
|
||||
whatever the variable is named). Watch for the same literal number showing
|
||||
up elsewhere in the file needing the same treatment: `configure_caddy_for_service`
|
||||
calls (container-internal side stays literal; a *bare*-port host-networking
|
||||
upstream, like `services/lyrion.sh`'s first instance, does need the
|
||||
variable), generated companion scripts (`services/koha.sh`'s
|
||||
`post-setup.sh`, `services/rustdesk.sh`'s relay-host messaging), `.env`
|
||||
values baked into client-facing config (`services/wg-easy.sh`'s `WG_PORT`
|
||||
env — WireGuard bakes the port into every generated peer config's
|
||||
`Endpoint =` line, so it must track the *actual* published port, not just
|
||||
the host-side compose mapping), and every `echo`/README line that prints
|
||||
`http://localhost:<port>`.
|
||||
|
||||
**Quoted (`<< 'MD'`) README heredocs don't interpolate — check before
|
||||
editing.** A `write_readme ... << MD` (unquoted) heredoc already
|
||||
interpolates `${WEB_PORT}` directly. A quoted `<< 'MD'` heredoc doesn't,
|
||||
and converting it means escaping *every* backtick used for inline-code
|
||||
formatting (`` \`...\` ``) — miss one and bash tries to execute it as a
|
||||
command substitution the next time the heredoc is read, the same class of
|
||||
bug the coturn.sh backtick incident was (see `services/coturn.sh`'s
|
||||
`write_readme` call). For a README with only one or two backticks,
|
||||
escaping them is fine. For one with many (`services/iopaint.sh`'s model
|
||||
reference table), it's safer to leave the heredoc quoted and patch the
|
||||
port into the *written* `README.md` afterward instead:
|
||||
```bash
|
||||
[ "$WEB_PORT" != "8100" ] && sed -i "s/localhost:8100/localhost:${WEB_PORT}/g" "$IOPAINT_DIR/README.md"
|
||||
```
|
||||
|
||||
**`network_mode: host` services can only scan what the app itself lets you
|
||||
override.** Host networking has no port *remapping* — whatever the app
|
||||
binds to on its fixed internal port is what's exposed, so `find_free_port`
|
||||
only helps for ports the app takes as configurable env vars.
|
||||
`services/lyrion.sh`'s `HTTP_PORT` env is genuinely configurable (LMS
|
||||
honors it as the actual bind port under host networking too — see its
|
||||
`_HTTP_PORT_INTERNAL` handling, which must track the scanned port rather
|
||||
than assuming bridge-mode's fixed `9000`), but its CLI (9090) and player
|
||||
(3483) ports are hardcoded in the image with no override — a collision
|
||||
there can only be warned about with `port_in_use`, not silently fixed.
|
||||
`services/homeassistant.sh` is the same shape: bridge mode (the default)
|
||||
scans freely; host mode (opt-in, for LAN device discovery) can only warn.
|
||||
|
||||
**Caddy is the one deliberate exception — never auto-scanned.**
|
||||
`services/caddy.sh` keeps 80/443 fixed and only warns via `port_in_use` if
|
||||
they're already taken. Every other service either points HTTPS clients at
|
||||
Caddy implicitly (browsers assume 443) or gets routed through it by
|
||||
domain; silently moving Caddy itself to a random port would leave nothing
|
||||
listening at the address any client actually tries, which is strictly
|
||||
worse than the collision it would be "fixing." If 80/443 are already
|
||||
bound, that's a real conflict (another web server on the host) the user
|
||||
needs to resolve directly.
|
||||
|
||||
## Chaining into another service from within your own
|
||||
|
||||
A service can call another service's `install_<name>()` directly as a
|
||||
|
||||
@@ -542,6 +542,45 @@ write_readme() {
|
||||
chown "$ACTUAL_USER:$ACTUAL_USER" "$dir/README.md" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# ── Host port collision avoidance (shared by every service that publishes a
|
||||
# fixed host port) ────────────────────────────────────────────────────────────
|
||||
# With 70+ services in this repo, several ship the same default port (e.g.
|
||||
# emby and jellyfin both default to 8096; changedetection and frigate both
|
||||
# default to 5000). Nothing enforced those defaults were actually free on the
|
||||
# host, so whichever service started its container second would fail to bind
|
||||
# ("port is already allocated") instead of just landing on the next free port.
|
||||
# Confirmed live: installing jellyfin after emby (or vice versa) writes a
|
||||
# docker-compose.yml claiming a port the other service's container already
|
||||
# holds, and only fails at `docker compose up` time — not at install time.
|
||||
#
|
||||
# port_in_use PORT [PROTO] — PROTO defaults to tcp; pass "udp" for UDP-only
|
||||
# ports. Returns 0 (true, in use) or 1 (free).
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
# find_free_port VARNAME START [PROTO]
|
||||
# Scans upward from START for a free host port and writes the result back
|
||||
# into VARNAME. Single-port convenience wrapper around port_in_use — every
|
||||
# service's install_<name>() should run its default/candidate port through
|
||||
# this (or a hand-rolled port_in_use loop for multiple ports that must move
|
||||
# together, e.g. a web port + an agent port) before writing docker-compose.yml,
|
||||
# not only when adding an explicit additional instance of itself. On a normal
|
||||
# single-install host this is a silent no-op (the default port is free, so
|
||||
# VARNAME comes back unchanged); it only changes behavior when something else
|
||||
# already holds the port, which is exactly the case that used to fail at
|
||||
# startup instead of at install time.
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
# ── Caddy reverse-proxy wiring (shared by every web service) ─────────────────
|
||||
# Usage: configure_caddy_for_service "Name" "UPSTREAM" "default-subdomain" ["extra"]
|
||||
# UPSTREAM: container:port for caddy_net routing (e.g. "filebrowser:80"),
|
||||
|
||||
+74
-12
@@ -41,6 +41,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
@@ -186,16 +201,60 @@ register_service actualbudget utilities "Open-source personal finance & budgetin
|
||||
install_actualbudget() {
|
||||
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 INSTANCE_SUFFIX="" CONTAINER="actualbudget"
|
||||
local WEB_PORT="5006"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Actual Budget would:"
|
||||
echo " - Create $AB_DIR with docker-compose.yml (data/)"
|
||||
echo " - Expose port 5006"
|
||||
echo " - Offer to add a new, separate instance if one already exists"
|
||||
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"
|
||||
return 0
|
||||
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"
|
||||
log_info "New instance: $AB_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Scan for a free port unconditionally — not just when adding an explicit
|
||||
# additional instance. A plain first install can just as easily collide
|
||||
# with an unrelated service that already claimed this default port (e.g.
|
||||
# emby and jellyfin both default to 8096) — see CLAUDE.md's "Port
|
||||
# collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
mkdir -p "$AB_DIR/data"
|
||||
ensure_docker_dir_ownership "$AB_DIR"
|
||||
cd "$AB_DIR" || return 1
|
||||
@@ -226,15 +285,15 @@ networks:
|
||||
fi
|
||||
|
||||
cat > docker-compose.yml << AB_COMPOSE
|
||||
name: actualbudget
|
||||
name: $CONTAINER
|
||||
|
||||
services:
|
||||
actualbudget:
|
||||
image: actualbudget/actual-server:latest
|
||||
container_name: actualbudget
|
||||
container_name: $CONTAINER
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5006:5006"
|
||||
- "${WEB_PORT}:5006"
|
||||
volumes:
|
||||
- ./data:/data
|
||||
env_file:
|
||||
@@ -248,17 +307,20 @@ CADDY_NET=$SITE_CADDY_NET
|
||||
AB_ENV
|
||||
|
||||
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
|
||||
# Actual Budget
|
||||
# Actual Budget${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||
|
||||
Open-source personal finance and budgeting tool. Supports bank sync via
|
||||
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/\`
|
||||
|
||||
## Manage
|
||||
@@ -276,13 +338,13 @@ docker compose pull && docker compose up -d # update
|
||||
MD
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
echo ""
|
||||
echo " Access at: http://localhost:5006"
|
||||
echo " Access at: http://localhost:${WEB_PORT}"
|
||||
echo " Bank sync: simplefin.org (optional, paid)"
|
||||
echo ""
|
||||
}
|
||||
|
||||
+35
-13
@@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
@@ -182,15 +197,22 @@ install_archivebox() {
|
||||
require_docker || return 1
|
||||
|
||||
local AB_DIR="$DOCKER_DIR/archivebox"
|
||||
local WEB_PORT="8000"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] archivebox would:"
|
||||
echo " - Create $AB_DIR with docker-compose.yml"
|
||||
echo " - Initialize ArchiveBox data directory"
|
||||
echo " - Expose port 8000 (web UI)"
|
||||
echo " - Expose port 8000 (web UI), auto-scanned for a free host port"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Scan for a free host port — this default (8000) isn't unique to
|
||||
# ArchiveBox in this repo, so a plain install shouldn't silently claim a
|
||||
# port another already-running service holds. See CLAUDE.md's "Port
|
||||
# collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
mkdir -p "$AB_DIR"
|
||||
ensure_docker_dir_ownership "$AB_DIR"
|
||||
cd "$AB_DIR" || return 1
|
||||
@@ -236,7 +258,7 @@ services:
|
||||
volumes:
|
||||
- ./data:/data
|
||||
ports:
|
||||
- "8000:8000"
|
||||
- "${WEB_PORT}:8000"
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
ABCOMPOSE
|
||||
|
||||
@@ -254,47 +276,47 @@ ABENV
|
||||
|
||||
configure_caddy_for_service "ArchiveBox" "archivebox:8000" "archive"
|
||||
|
||||
write_readme "$AB_DIR" << 'MD'
|
||||
write_readme "$AB_DIR" << MD
|
||||
# ArchiveBox
|
||||
|
||||
Self-hosted web archiving — saves full snapshots of web pages (HTML, screenshots,
|
||||
PDFs, WARC) like a personal Wayback Machine.
|
||||
|
||||
- Web UI: http://localhost:8000
|
||||
- Web UI: http://localhost:${WEB_PORT}
|
||||
|
||||
## Manage
|
||||
```bash
|
||||
\`\`\`bash
|
||||
cd ~/docker/archivebox
|
||||
docker compose up -d # start
|
||||
docker compose down # stop
|
||||
docker compose logs -f # logs
|
||||
docker compose pull && docker compose up -d # update
|
||||
```
|
||||
\`\`\`
|
||||
|
||||
## Add URLs to archive
|
||||
```bash
|
||||
# Via web UI — visit http://localhost:8000 and use the Add page
|
||||
\`\`\`bash
|
||||
# Via web UI — visit http://localhost:${WEB_PORT} and use the Add page
|
||||
# Via CLI:
|
||||
echo "https://example.com" | docker compose run --rm archivebox add
|
||||
docker compose run --rm archivebox add --depth=1 https://example.com
|
||||
```
|
||||
\`\`\`
|
||||
|
||||
## Create admin user
|
||||
```bash
|
||||
\`\`\`bash
|
||||
docker compose run --rm archivebox manage createsuperuser
|
||||
```
|
||||
\`\`\`
|
||||
MD
|
||||
|
||||
local START_AB=""
|
||||
prompt_yn "Start ArchiveBox now? (y/n):" "y" START_AB
|
||||
if [ "$START_AB" = "y" ] || [ "$START_AB" = "Y" ]; then
|
||||
docker compose up -d \
|
||||
&& log_success "ArchiveBox started — http://localhost:8000" \
|
||||
&& log_success "ArchiveBox started — http://localhost:${WEB_PORT}" \
|
||||
|| log_warning "Start failed — check: docker compose logs"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " Web UI: http://localhost:8000"
|
||||
echo " Web UI: http://localhost:${WEB_PORT}"
|
||||
echo " Add URLs via the web UI or: echo 'URL' | docker compose run --rm archivebox add"
|
||||
echo ""
|
||||
}
|
||||
|
||||
+26
-4
@@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
@@ -198,6 +213,7 @@ install_arm() {
|
||||
|
||||
local ARM_DIR="$DOCKER_DIR/arm"
|
||||
local DEFAULT_OUTPUT="$ACTUAL_HOME/ripped"
|
||||
local WEB_PORT="8080"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] A.R.M. would:"
|
||||
@@ -205,7 +221,7 @@ install_arm() {
|
||||
echo " - Detect optical drives (/dev/sr*) — defaults to /dev/sr0"
|
||||
echo " - Create ripped output dirs (movies/ music/) under $DEFAULT_OUTPUT"
|
||||
echo " - Run as UID/GID $(id -u "$ACTUAL_USER")/$(id -g "$ACTUAL_USER") with privileged: true"
|
||||
echo " - Expose port 8080"
|
||||
echo " - Expose port 8080 (auto-scanned for a free host port)"
|
||||
echo " - Offer a Caddy reverse proxy and to start the container"
|
||||
return 0
|
||||
fi
|
||||
@@ -225,6 +241,12 @@ install_arm() {
|
||||
OPTICAL_DRIVES="/dev/sr0"
|
||||
fi
|
||||
|
||||
# Scan for a free host port — this default (8080) isn't unique to A.R.M.
|
||||
# in this repo (nextcloud also defaults to 8080), so a plain install
|
||||
# shouldn't silently claim a port another already-running service holds.
|
||||
# See CLAUDE.md's "Port collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
mkdir -p "$ARM_DIR"
|
||||
ensure_docker_dir_ownership "$ARM_DIR"
|
||||
cd "$ARM_DIR" || return 1
|
||||
@@ -275,7 +297,7 @@ services:
|
||||
- \${ARM_OUTPUT}/movies:/home/arm/media/completed
|
||||
- \${ARM_OUTPUT}/music:/home/arm/music
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "${WEB_PORT}:8080"
|
||||
devices:
|
||||
- /dev/sr0:/dev/sr0
|
||||
# Add more optical drives as needed:
|
||||
@@ -303,7 +325,7 @@ ARM_ENV
|
||||
Auto-rips DVDs, Blu-rays, and CDs when you insert them — identifies the disc,
|
||||
fetches metadata, and transcodes to a usable format.
|
||||
|
||||
- Web UI: http://localhost:8080 (complete setup on first visit)
|
||||
- Web UI: http://localhost:${WEB_PORT} (complete setup on first visit)
|
||||
- Ripped output: \`$ARM_OUTPUT\` → movies and music subdirs
|
||||
- App data: \`config/\` and \`logs/\`
|
||||
|
||||
@@ -338,7 +360,7 @@ MD
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " Access at: http://localhost:8080"
|
||||
echo " Access at: http://localhost:${WEB_PORT}"
|
||||
echo " Complete setup in browser on first visit."
|
||||
echo ""
|
||||
}
|
||||
|
||||
@@ -41,6 +41,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
@@ -232,14 +247,16 @@ install_audiobookshelf() {
|
||||
ABS_DIR="$DOCKER_DIR/audiobookshelf-$_suffix"
|
||||
CONTAINER="audiobookshelf-$_suffix"
|
||||
DEFAULT_AUDIOBOOKS="$ACTUAL_HOME/audiobooks-$_suffix"
|
||||
|
||||
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
done
|
||||
log_info "New instance: $ABS_DIR (port $WEB_PORT)"
|
||||
log_info "New instance: $ABS_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Scan for a free port unconditionally — not just when adding an explicit
|
||||
# additional instance. A plain first install can just as easily collide
|
||||
# with an unrelated service that already claimed this default port — see
|
||||
# CLAUDE.md's "Port collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
local AUDIOBOOKS_PATH=""
|
||||
prompt_text "Path to audiobooks folder [$DEFAULT_AUDIOBOOKS]:" "$DEFAULT_AUDIOBOOKS" AUDIOBOOKS_PATH
|
||||
AUDIOBOOKS_PATH="${AUDIOBOOKS_PATH/#\~/$ACTUAL_HOME}"; AUDIOBOOKS_PATH="${AUDIOBOOKS_PATH%/}"
|
||||
|
||||
@@ -46,6 +46,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
|
||||
@@ -52,6 +52,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
@@ -233,6 +248,22 @@ install_caddy() {
|
||||
fi
|
||||
fi
|
||||
|
||||
# Unlike every other service, Caddy's ports (80/443) are NOT auto-scanned
|
||||
# or shifted on a collision — see CLAUDE.md's "Port collision avoidance"
|
||||
# section. Every other service in this repo either points HTTPS clients
|
||||
# at Caddy implicitly (browsers assume 443) or gets its own reverse-proxy
|
||||
# domain through it; silently moving Caddy to a random port would leave
|
||||
# nothing at the address clients actually try, which is strictly worse
|
||||
# than the collision itself. If 80/443 are already taken, that's a real
|
||||
# conflict (another web server already bound to those ports) the user
|
||||
# needs to resolve directly — warn loudly instead of masking it.
|
||||
if port_in_use 80 || port_in_use 443; then
|
||||
log_warning "Port 80 and/or 443 is already in use by another process."
|
||||
log_warning "Caddy needs both to serve HTTPS — find what's using them"
|
||||
log_warning "(sudo ss -tlnp 'sport = :80' / ':443') and stop it, or Caddy"
|
||||
log_warning "will fail to start. Continuing anyway — this is not auto-fixed."
|
||||
fi
|
||||
|
||||
mkdir -p "$CADDY_DIR/data" "$CADDY_DIR/config"
|
||||
ensure_docker_dir_ownership "$CADDY_DIR"
|
||||
|
||||
|
||||
+30
-8
@@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
@@ -186,13 +201,20 @@ install_calibre-web() {
|
||||
log_info "Installing Calibre-Web..."
|
||||
|
||||
local CW_DIR="$DOCKER_DIR/calibre-web"
|
||||
local WEB_PORT="8083"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would create $CW_DIR"
|
||||
echo "[DRY-RUN] Would write docker-compose.yml and .env"
|
||||
echo "[DRY-RUN] Would auto-scan for a free host port"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Scan for a free host port — a plain install shouldn't silently claim a
|
||||
# port another already-running service holds. See CLAUDE.md's "Port
|
||||
# collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
mkdir -p "$CW_DIR/config" "$CW_DIR/books"
|
||||
ensure_docker_dir_ownership "$CW_DIR"
|
||||
cd "$CW_DIR" || return 1
|
||||
@@ -238,7 +260,7 @@ services:
|
||||
- ./config:/config
|
||||
- ./books:/books
|
||||
ports:
|
||||
- "8083:8083"
|
||||
- "${WEB_PORT}:8083"
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
CW_COMPOSE
|
||||
|
||||
@@ -253,20 +275,20 @@ CW_ENV
|
||||
|
||||
configure_caddy_for_service "Calibre-Web" "calibre-web:8083" "books"
|
||||
|
||||
write_readme "$CW_DIR" << 'MD'
|
||||
write_readme "$CW_DIR" << MD
|
||||
# Calibre-Web
|
||||
|
||||
Web-based ebook library with metadata editing, reading, and format conversion
|
||||
powered by Calibre.
|
||||
|
||||
## First-run setup
|
||||
1. Open the UI (http://localhost:8083) and log in with admin / admin123
|
||||
2. When prompted for the database location, enter: `/books`
|
||||
1. Open the UI (http://localhost:${WEB_PORT}) and log in with admin / admin123
|
||||
2. When prompted for the database location, enter: \`/books\`
|
||||
(point this at your existing Calibre library or an empty directory)
|
||||
3. Change the default password immediately under Admin → Edit User
|
||||
|
||||
## Ebook conversion
|
||||
The `DOCKER_MODS=linuxserver/mods:universal-calibre` environment variable
|
||||
The \`DOCKER_MODS=linuxserver/mods:universal-calibre\` environment variable
|
||||
installs the full Calibre binary inside the container, enabling on-the-fly
|
||||
ebook conversion (e.g. EPUB → MOBI/AZW3).
|
||||
|
||||
@@ -275,15 +297,15 @@ Place your Calibre library (or individual books) in:
|
||||
~/docker/calibre-web/books/
|
||||
|
||||
If you already have a Calibre library elsewhere, mount that path instead by
|
||||
editing the `./books:/books` volume line in docker-compose.yml.
|
||||
editing the \`./books:/books\` volume line in docker-compose.yml.
|
||||
|
||||
## Manage
|
||||
```bash
|
||||
\`\`\`bash
|
||||
docker compose up -d
|
||||
docker compose down
|
||||
docker compose logs -f
|
||||
docker compose pull && docker compose down && docker compose up -d
|
||||
```
|
||||
\`\`\`
|
||||
MD
|
||||
|
||||
local START_CW=""
|
||||
|
||||
@@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
@@ -186,13 +201,21 @@ install_changedetection() {
|
||||
log_info "Installing Changedetection.io..."
|
||||
|
||||
local CD_DIR="$DOCKER_DIR/changedetection"
|
||||
local WEB_PORT="5000"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would create $CD_DIR"
|
||||
echo "[DRY-RUN] Would write docker-compose.yml and .env"
|
||||
echo "[DRY-RUN] Would auto-scan for a free host port"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Scan for a free host port — this default (5000) isn't unique to
|
||||
# Changedetection.io in this repo (frigate also defaults to 5000), so a
|
||||
# plain install shouldn't silently claim a port another already-running
|
||||
# service holds. See CLAUDE.md's "Port collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
mkdir -p "$CD_DIR/data"
|
||||
ensure_docker_dir_ownership "$CD_DIR"
|
||||
cd "$CD_DIR" || return 1
|
||||
@@ -230,7 +253,7 @@ services:
|
||||
hostname: changedetection
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5000:5000"
|
||||
- "${WEB_PORT}:5000"
|
||||
environment:
|
||||
- BASE_URL=\${BASE_URL}
|
||||
- PLAYWRIGHT_DRIVER_URL=ws://playwright-chrome:3000
|
||||
@@ -263,7 +286,7 @@ CD_ENV
|
||||
|
||||
configure_caddy_for_service "Changedetection" "changedetection:5000" "changes"
|
||||
|
||||
write_readme "$CD_DIR" << 'MD'
|
||||
write_readme "$CD_DIR" << MD
|
||||
# Changedetection.io
|
||||
|
||||
Monitor web pages for changes and receive notifications via email, Slack,
|
||||
@@ -271,20 +294,20 @@ Discord, ntfy, and many other channels. Includes a Playwright/Chrome sidecar
|
||||
for JavaScript-heavy pages.
|
||||
|
||||
## Access
|
||||
- URL: http://localhost:5000
|
||||
- URL: http://localhost:${WEB_PORT}
|
||||
- Optional password can be set in Settings → General within the UI.
|
||||
|
||||
## Manage
|
||||
```bash
|
||||
\`\`\`bash
|
||||
docker compose up -d # start
|
||||
docker compose down # stop
|
||||
docker compose logs -f # logs
|
||||
docker compose pull && docker compose down && docker compose up -d # update
|
||||
```
|
||||
\`\`\`
|
||||
|
||||
## Environment
|
||||
Edit `.env` to change `BASE_URL` (used for notification links),
|
||||
then restart: `docker compose down && docker compose up -d`
|
||||
Edit \`.env\` to change \`BASE_URL\` (used for notification links),
|
||||
then restart: \`docker compose down && docker compose up -d\`
|
||||
MD
|
||||
|
||||
local START_CD=""
|
||||
@@ -295,7 +318,7 @@ MD
|
||||
|| log_warning "Failed to start — check: docker compose logs"
|
||||
fi
|
||||
|
||||
echo " Access at: http://localhost:5000"
|
||||
echo " Access at: http://localhost:${WEB_PORT}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
@@ -187,15 +202,23 @@ install_drum-rhythm-game() {
|
||||
|
||||
local DRUM_DIR="$DOCKER_DIR/drum-rhythm-game"
|
||||
local REPO_URL="https://github.com/outis1one/drum-rhythm-game.git"
|
||||
local WEB_PORT="8096"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] drum-rhythm-game would:"
|
||||
echo " - Clone $REPO_URL to $DRUM_DIR/html"
|
||||
echo " - Build the repo's own Dockerfile (nginx, gzip, /healthz) on port 8096"
|
||||
echo " (auto-scanned for a free host port — 8096 is also emby/jellyfin's default)"
|
||||
echo " - Offer Authelia SSO protection via Caddy (no built-in auth)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Scan for a free host port — this default (8096) is also emby's and
|
||||
# jellyfin's default, so a plain install shouldn't silently claim a port
|
||||
# another already-running service holds. See CLAUDE.md's "Port collision
|
||||
# avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
mkdir -p "$DRUM_DIR"
|
||||
ensure_docker_dir_ownership "$DRUM_DIR"
|
||||
cd "$DRUM_DIR" || return 1
|
||||
@@ -248,7 +271,7 @@ services:
|
||||
hostname: drum-rhythm-game
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8096:80"
|
||||
- "${WEB_PORT}:80"
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
DRUM_COMPOSE
|
||||
|
||||
@@ -268,7 +291,7 @@ DRUM_ENV
|
||||
fi
|
||||
configure_caddy_for_service "Drum Rhythm Game" "drum-rhythm-game:80" "drums" "$DRUM_EXTRA_BLOCK"
|
||||
|
||||
write_readme "$DRUM_DIR" << 'MD'
|
||||
write_readme "$DRUM_DIR" << MD
|
||||
# Drum Rhythm Game
|
||||
|
||||
Browser-based drum rhythm game — 18 genres covering 119 synth-orchestra
|
||||
@@ -281,36 +304,36 @@ Multiplayer take-turns mode, adjustable speed/volume, and a leaderboard —
|
||||
all state lives in browser localStorage. No server required; all audio is
|
||||
synthesized in-browser via the Web Audio API.
|
||||
|
||||
Built and served from the repo's own `Dockerfile` (nginx + gzip + a
|
||||
`/healthz` endpoint) — only `index.html` ends up in the image, so the
|
||||
Built and served from the repo's own \`Dockerfile\` (nginx + gzip + a
|
||||
\`/healthz\` endpoint) — only \`index.html\` ends up in the image, so the
|
||||
repo's docs/license/dev files never get served.
|
||||
|
||||
Source: https://github.com/outis1one/drum-rhythm-game
|
||||
|
||||
## Access
|
||||
- URL: http://localhost:8096
|
||||
- URL: http://localhost:${WEB_PORT}
|
||||
|
||||
## Manage
|
||||
```bash
|
||||
\`\`\`bash
|
||||
cd ~/docker/drum-rhythm-game
|
||||
docker compose up -d # start
|
||||
docker compose down # stop
|
||||
docker compose logs -f # logs
|
||||
```
|
||||
\`\`\`
|
||||
|
||||
## Update game
|
||||
```bash
|
||||
\`\`\`bash
|
||||
cd ~/docker/drum-rhythm-game
|
||||
git -C html pull
|
||||
docker compose up -d --build
|
||||
```
|
||||
\`\`\`
|
||||
MD
|
||||
|
||||
local START_DRUM=""
|
||||
prompt_yn "Start drum-rhythm-game now? (y/n):" "y" START_DRUM
|
||||
if [ "$START_DRUM" = "y" ] || [ "$START_DRUM" = "Y" ]; then
|
||||
docker compose up -d --build \
|
||||
&& log_success "Drum Rhythm Game started — http://localhost:8096" \
|
||||
&& log_success "Drum Rhythm Game started — http://localhost:${WEB_PORT}" \
|
||||
|| log_warning "Start failed — check: docker compose logs"
|
||||
fi
|
||||
|
||||
|
||||
+24
-8
@@ -49,6 +49,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
@@ -246,17 +261,18 @@ install_emby() {
|
||||
EMBY_DIR="$DOCKER_DIR/emby-$_suffix"
|
||||
CONTAINER="emby-$_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
|
||||
while ss -tlnH "sport = :${HTTPS_PORT}" 2>/dev/null | grep -q .; do
|
||||
HTTPS_PORT=$((HTTPS_PORT + 1))
|
||||
done
|
||||
log_info "New instance: $EMBY_DIR (web port $WEB_PORT, https port $HTTPS_PORT)"
|
||||
log_info "New instance: $EMBY_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Scan for free ports unconditionally — not just when adding an explicit
|
||||
# additional instance. A plain first install can just as easily collide
|
||||
# with an unrelated service that already claimed these default ports
|
||||
# (e.g. jellyfin also defaults to 8096) — see CLAUDE.md's "Port collision
|
||||
# avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
find_free_port HTTPS_PORT "$HTTPS_PORT"
|
||||
|
||||
local MUSIC_ONLY=""
|
||||
prompt_yn "Set this up as a music-only server (skip movies/TV)? (y/n):" "n" MUSIC_ONLY
|
||||
|
||||
|
||||
+74
-12
@@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
@@ -184,14 +199,58 @@ install_filebrowser() {
|
||||
require_docker || return 1
|
||||
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 INSTANCE_SUFFIX="" CONTAINER="filebrowser"
|
||||
local WEB_PORT="8085"
|
||||
|
||||
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 auto-scan for a free host port if this is an additional instance"
|
||||
return 0
|
||||
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"
|
||||
log_info "New instance: $FB_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Scan for a free port unconditionally — not just when adding an explicit
|
||||
# additional instance. A plain first install can just as easily collide
|
||||
# with an unrelated service that already claimed this default port — see
|
||||
# CLAUDE.md's "Port collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
mkdir -p "$FB_DIR/data"
|
||||
ensure_docker_dir_ownership "$FB_DIR"
|
||||
cd "$FB_DIR" || return 1
|
||||
@@ -223,13 +282,13 @@ networks:
|
||||
fi
|
||||
|
||||
cat > docker-compose.yml << FB_COMPOSE
|
||||
name: filebrowser
|
||||
name: $CONTAINER
|
||||
|
||||
services:
|
||||
filebrowser:
|
||||
image: gtstef/filebrowser:stable
|
||||
container_name: filebrowser
|
||||
hostname: filebrowser
|
||||
container_name: $CONTAINER
|
||||
hostname: $CONTAINER
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=${SITE_TZ:-UTC}
|
||||
@@ -237,7 +296,7 @@ services:
|
||||
- ./data:/home/filebrowser/data
|
||||
- ${FB_PATH}:/files
|
||||
ports:
|
||||
- "8085:80"
|
||||
- "${WEB_PORT}:80"
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
FB_COMPOSE
|
||||
|
||||
@@ -282,18 +341,21 @@ FB_CONFIG
|
||||
fi
|
||||
|
||||
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
|
||||
# FileBrowser Quantum
|
||||
# FileBrowser Quantum${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||
|
||||
Web-based file manager with multi-source support, office preview, and
|
||||
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
|
||||
- URL: http://localhost:8085
|
||||
- URL: http://localhost:${WEB_PORT}
|
||||
- Default login: admin / admin (change immediately!)
|
||||
|
||||
## Adding sources (extra directories)
|
||||
@@ -320,14 +382,14 @@ docker compose pull && docker compose down && docker compose up -d # update
|
||||
MD
|
||||
|
||||
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
|
||||
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"
|
||||
fi
|
||||
|
||||
echo " Access at: http://localhost:8085"
|
||||
echo " Access at: http://localhost:${WEB_PORT}"
|
||||
echo " Default login: admin / admin (change immediately!)"
|
||||
echo ""
|
||||
}
|
||||
|
||||
+75
-14
@@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
@@ -196,17 +211,60 @@ register_service fmd utilities "Android device tracking — alternative to Googl
|
||||
install_fmd() {
|
||||
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 INSTANCE_SUFFIX="" CONTAINER="fmd"
|
||||
local WEB_PORT="8084"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
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 " - 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"
|
||||
return 0
|
||||
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"
|
||||
log_info "New instance: $FMD_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Scan for a free port unconditionally — not just when adding an explicit
|
||||
# additional instance. A plain first install can just as easily collide
|
||||
# with an unrelated service that already claimed this default port — see
|
||||
# CLAUDE.md's "Port collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
mkdir -p "$FMD_DIR"
|
||||
ensure_docker_dir_ownership "$FMD_DIR"
|
||||
cd "$FMD_DIR" || return 1
|
||||
@@ -238,20 +296,20 @@ networks:
|
||||
fi
|
||||
|
||||
cat > docker-compose.yml << FMD_COMPOSE
|
||||
name: fmd
|
||||
name: $CONTAINER
|
||||
|
||||
services:
|
||||
fmd:
|
||||
image: nulide/findmydevice
|
||||
container_name: fmd
|
||||
hostname: fmd
|
||||
container_name: $CONTAINER
|
||||
hostname: $CONTAINER
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- FMD_ADMIN_PASSWORD=\${FMD_ADMIN_PASSWORD}
|
||||
volumes:
|
||||
- ./data:/fmd/data
|
||||
ports:
|
||||
- "8084:8080"
|
||||
- "${WEB_PORT}:8080"
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
FMD_COMPOSE
|
||||
|
||||
@@ -262,17 +320,20 @@ FMD_ENV
|
||||
|
||||
mkdir -p data
|
||||
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
|
||||
# FindMyDevice (FMD)
|
||||
# FindMyDevice (FMD)${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||
|
||||
Self-hosted Android device tracking — locate, lock, or wipe your 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\`)
|
||||
- App data: \`data/\`
|
||||
|
||||
@@ -287,19 +348,19 @@ docker compose pull && docker compose up -d # update
|
||||
|
||||
## Mobile app
|
||||
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\`
|
||||
3. Grant location and accessibility permissions
|
||||
MD
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
echo ""
|
||||
echo " Access at: http://localhost:8084"
|
||||
echo " Access at: http://localhost:${WEB_PORT}"
|
||||
echo " Password: $FMD_PASS (saved in .env)"
|
||||
echo " Mobile app: FindMyDevice on F-Droid"
|
||||
echo ""
|
||||
|
||||
@@ -62,6 +62,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
generate_password() {
|
||||
local _len="${1:-32}"
|
||||
tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c "$_len"
|
||||
@@ -216,6 +231,7 @@ install_frigate-audio() {
|
||||
require_docker || return 1
|
||||
|
||||
local DIR="$DOCKER_DIR/frigate-audio"
|
||||
local WEB_PORT="8971" DEBUG_PORT="5001" RTSP_PORT="8554" WEBRTC_PORT="8555" MQTT_PORT="1883"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] frigate-audio would:"
|
||||
@@ -223,9 +239,27 @@ install_frigate-audio() {
|
||||
echo " Prompt for camera RTSP credentials, IPs, MQTT password, ntfy server"
|
||||
echo " Generate docker-compose.yml, frigate config, mosquitto config, .env"
|
||||
echo " Bootstrap the Mosquitto password file"
|
||||
echo " Ports 8971/5001/8554/8555/1883 auto-scanned/shifted together if occupied"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Scan for free host ports, moving all 5 together — a plain install
|
||||
# shouldn't silently claim a port another already-running service holds
|
||||
# (e.g. 1883 is the standard MQTT port and could already be in use by a
|
||||
# Home Assistant add-on or another broker). Container-internal ports and
|
||||
# container-to-container references (mosquitto:1883, 127.0.0.1:5000/8554
|
||||
# inside the frigate container) are unaffected by the host-side ports
|
||||
# scanned here. See CLAUDE.md's "Port collision avoidance" section.
|
||||
while port_in_use "$WEB_PORT" || port_in_use "$DEBUG_PORT" \
|
||||
|| port_in_use "$RTSP_PORT" || port_in_use "$WEBRTC_PORT" \
|
||||
|| port_in_use "$WEBRTC_PORT" udp || port_in_use "$MQTT_PORT"; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
DEBUG_PORT=$((DEBUG_PORT + 1))
|
||||
RTSP_PORT=$((RTSP_PORT + 1))
|
||||
WEBRTC_PORT=$((WEBRTC_PORT + 1))
|
||||
MQTT_PORT=$((MQTT_PORT + 1))
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "╔═══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Frigate + Mosquitto + frigate-notify (audio-ready stack) ║"
|
||||
@@ -434,11 +468,11 @@ COMPOSEEOF
|
||||
tmpfs:
|
||||
size: 1000000000
|
||||
ports:
|
||||
- "8971:8971"
|
||||
- "5001:5000"
|
||||
- "8554:8554"
|
||||
- "8555:8555/tcp"
|
||||
- "8555:8555/udp"
|
||||
- "${WEB_PORT}:8971"
|
||||
- "${DEBUG_PORT}:5000"
|
||||
- "${RTSP_PORT}:8554"
|
||||
- "${WEBRTC_PORT}:8555/tcp"
|
||||
- "${WEBRTC_PORT}:8555/udp"
|
||||
${_CADDY_NET_BLOCK} healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://127.0.0.1:5000/api/version"]
|
||||
interval: 10s
|
||||
@@ -452,7 +486,7 @@ ${_CADDY_NET_BLOCK} healthcheck:
|
||||
image: eclipse-mosquitto:2
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "1883:1883"
|
||||
- "${MQTT_PORT}:1883"
|
||||
volumes:
|
||||
- ./mosquitto/config:/mosquitto/config
|
||||
- ./mosquitto/data:/mosquitto/data
|
||||
@@ -760,7 +794,7 @@ FNEOF
|
||||
if [[ ${START_NOW:-n} =~ ^[Yy]$ ]]; then
|
||||
log_info "Starting frigate-audio stack..."
|
||||
if ( cd "$DIR" && docker compose up -d ); then
|
||||
log_success "Stack started — Frigate UI: http://localhost:8971"
|
||||
log_success "Stack started — Frigate UI: http://localhost:${WEB_PORT}"
|
||||
else
|
||||
log_warning "Start failed — check: cd $DIR && docker compose logs"
|
||||
fi
|
||||
|
||||
+40
-9
@@ -52,6 +52,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
@@ -497,12 +512,15 @@ install_frigate() {
|
||||
require_docker || return 1
|
||||
|
||||
local FRIGATE_DIR="$DOCKER_DIR/frigate"
|
||||
local WEB_PORT="5000" RTSP_PORT="8554" WEBRTC_PORT="8555"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Frigate would:"
|
||||
echo " - Create $FRIGATE_DIR with docker-compose.yml + .env + config/config.yml"
|
||||
echo " - Auto-enable /dev/dri/renderD128 for GPU-assisted detection if present"
|
||||
echo " - Expose ports 5000 (web), 8554 (RTSP restream), 8555 (WebRTC)"
|
||||
echo " — all 3 auto-scanned/shifted together if occupied (5000 is also"
|
||||
echo " changedetection's default)"
|
||||
echo " - If already configured: show existing cameras and offer to keep,"
|
||||
echo " back up + start fresh, add more, or remove some"
|
||||
echo " - Prompt to add cameras interactively (RTSP creds go in .env)"
|
||||
@@ -511,6 +529,19 @@ install_frigate() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Scan for free host ports, moving all 3 together — a plain install
|
||||
# shouldn't silently claim a port another already-running service holds
|
||||
# (changedetection.io also defaults to 5000). See CLAUDE.md's "Port
|
||||
# collision avoidance" section. Internal go2rtc restream URLs elsewhere
|
||||
# in this file (rtsp://127.0.0.1:8554/...) are container-internal
|
||||
# loopback references, unaffected by the host-side port used here.
|
||||
while port_in_use "$WEB_PORT" || port_in_use "$RTSP_PORT" \
|
||||
|| port_in_use "$WEBRTC_PORT" || port_in_use "$WEBRTC_PORT" udp; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
RTSP_PORT=$((RTSP_PORT + 1))
|
||||
WEBRTC_PORT=$((WEBRTC_PORT + 1))
|
||||
done
|
||||
|
||||
mkdir -p "$FRIGATE_DIR"
|
||||
ensure_docker_dir_ownership "$FRIGATE_DIR"
|
||||
cd "$FRIGATE_DIR" || return 1
|
||||
@@ -605,10 +636,10 @@ $DEVICE_BLOCK
|
||||
tmpfs:
|
||||
size: 1000000000
|
||||
ports:
|
||||
- "5000:5000"
|
||||
- "8554:8554"
|
||||
- "8555:8555/tcp"
|
||||
- "8555:8555/udp"
|
||||
- "${WEB_PORT}:5000"
|
||||
- "${RTSP_PORT}:8554"
|
||||
- "${WEBRTC_PORT}:8555/tcp"
|
||||
- "${WEBRTC_PORT}:8555/udp"
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
FRIGATE_COMPOSE
|
||||
|
||||
@@ -709,9 +740,9 @@ FRIGATE_ENV
|
||||
AI-powered network video recorder with real-time object detection for
|
||||
security cameras. Detects people, cars, animals, and more.
|
||||
|
||||
- Web UI: http://localhost:5000
|
||||
- RTSP restream: port 8554
|
||||
- WebRTC: port 8555
|
||||
- Web UI: http://localhost:${WEB_PORT}
|
||||
- RTSP restream: port ${RTSP_PORT}
|
||||
- WebRTC: port ${WEBRTC_PORT}
|
||||
- Recordings: \`$FRIGATE_MEDIA\`
|
||||
- Config: \`config/config.yml\` — cameras configured during install (${#CAM_NAME[@]} total)
|
||||
- Credentials: \`.env\` — RTSP user/pass/IP per camera as FRIGATE_* variables
|
||||
@@ -736,7 +767,7 @@ container startup.
|
||||
## First steps
|
||||
1. Review \`config/config.yml\` — adjust detection zones, masks, retention
|
||||
2. Start Frigate: \`docker compose up -d\`
|
||||
3. Open http://localhost:5000 to view cameras and configure detection zones
|
||||
3. Open http://localhost:${WEB_PORT} to view cameras and configure detection zones
|
||||
|
||||
## Hardware acceleration
|
||||
- Intel/AMD GPU: uncomment the \`devices: [/dev/dri/renderD128]\` block
|
||||
@@ -761,7 +792,7 @@ MD
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " Access at: http://localhost:5000"
|
||||
echo " Access at: http://localhost:${WEB_PORT}"
|
||||
echo " Config: $FRIGATE_DIR/config/config.yml"
|
||||
echo ""
|
||||
}
|
||||
|
||||
+26
-4
@@ -47,6 +47,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
@@ -196,14 +211,21 @@ install_gatus() {
|
||||
require_docker || return 1
|
||||
log_info "Installing Gatus..."
|
||||
local GATUS_DIR="$DOCKER_DIR/gatus"
|
||||
local WEB_PORT="8086"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would create $GATUS_DIR (gatus_config/, gatus_data/)"
|
||||
echo "[DRY-RUN] Would deploy twinproduction/gatus:latest"
|
||||
echo "[DRY-RUN] Port 8086 published, config at gatus_config/config.yaml"
|
||||
echo "[DRY-RUN] Port 8086 published (auto-scanned for a free host port),"
|
||||
echo "[DRY-RUN] config at gatus_config/config.yaml"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Scan for a free host port — a plain install shouldn't silently claim a
|
||||
# port another already-running service holds. See CLAUDE.md's "Port
|
||||
# collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
mkdir -p "$GATUS_DIR/gatus_config" "$GATUS_DIR/gatus_data"
|
||||
ensure_docker_dir_ownership "$GATUS_DIR"
|
||||
cd "$GATUS_DIR" || return 1
|
||||
@@ -244,7 +266,7 @@ services:
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
ports:
|
||||
- "8086:8080"
|
||||
- "${WEB_PORT}:8080"
|
||||
volumes:
|
||||
- ./gatus_config:/config
|
||||
- ./gatus_data:/data
|
||||
@@ -324,7 +346,7 @@ GATUS_CFG
|
||||
Clean, self-hosted status page. Polls HTTP, TCP, DNS, and ICMP endpoints.
|
||||
|
||||
## Access
|
||||
- URL: http://localhost:8086
|
||||
- URL: http://localhost:${WEB_PORT}
|
||||
|
||||
## Configuration
|
||||
Edit \`gatus_config/config.yaml\` — changes are **hot-reloaded** without restarting.
|
||||
@@ -355,7 +377,7 @@ MD
|
||||
|| log_warning "Failed to start — check: docker compose logs"
|
||||
fi
|
||||
|
||||
echo " Access at: http://localhost:8086"
|
||||
echo " Access at: http://localhost:${WEB_PORT}"
|
||||
echo " Config: $GATUS_DIR/gatus_config/config.yaml (hot-reloaded)"
|
||||
echo ""
|
||||
}
|
||||
|
||||
@@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
@@ -189,9 +204,13 @@ install_homeassistant() {
|
||||
require_docker || return 1
|
||||
log_info "Installing Home Assistant..."
|
||||
local HOMEASSISTANT_DIR="$DOCKER_DIR/homeassistant"
|
||||
local WEB_PORT="8123"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would create $HOMEASSISTANT_DIR"
|
||||
echo "[DRY-RUN] Bridge mode would auto-scan for a free host port; host mode"
|
||||
echo "[DRY-RUN] can only warn on a collision (Home Assistant's own port isn't"
|
||||
echo "[DRY-RUN] configurable, and host networking has no remapping)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -224,16 +243,25 @@ install_homeassistant() {
|
||||
HA_NET_LINES=" network_mode: host"
|
||||
HA_CADDY_NET_LINES=""
|
||||
echo " → Host networking selected (best device discovery)."
|
||||
# Host mode has no port remapping and Home Assistant's own port isn't
|
||||
# configurable via env var — a collision here can only be warned
|
||||
# about, not silently fixed. See CLAUDE.md's "Port collision
|
||||
# avoidance" section.
|
||||
port_in_use "$WEB_PORT" && log_warning "Port $WEB_PORT is already in use by another process — Home Assistant won't be reachable until that's resolved (this port isn't configurable with host networking)."
|
||||
else
|
||||
# Bridge mode can freely remap — scan for a free host port so a
|
||||
# plain install doesn't silently claim a port another
|
||||
# already-running service holds.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
HA_NET_LINES=" ports:
|
||||
- \"8123:8123\""
|
||||
- \"${WEB_PORT}:8123\""
|
||||
if [ "$_CADDY_MODE" = "local" ]; then
|
||||
HA_CADDY_NET_LINES=" networks:
|
||||
- caddy_net"
|
||||
else
|
||||
HA_CADDY_NET_LINES=""
|
||||
fi
|
||||
echo " → Bridge networking selected (port 8123 published)."
|
||||
echo " → Bridge networking selected (port $WEB_PORT published)."
|
||||
fi
|
||||
|
||||
local _CADDY_NET_SECTION=""
|
||||
@@ -292,7 +320,7 @@ HA_CONFIG
|
||||
log_success "Home Assistant configured at $HOMEASSISTANT_DIR"
|
||||
|
||||
if [ "$HA_NETMODE" = "2" ]; then
|
||||
configure_caddy_for_service "Home Assistant" "8123" "home"
|
||||
configure_caddy_for_service "Home Assistant" "$WEB_PORT" "home"
|
||||
else
|
||||
configure_caddy_for_service "Home Assistant" "homeassistant:8123" "home"
|
||||
fi
|
||||
@@ -303,7 +331,7 @@ HA_CONFIG
|
||||
Home automation hub. Built-in auth — no Authelia needed.
|
||||
|
||||
## Access
|
||||
- URL: http://localhost:8123
|
||||
- URL: http://localhost:${WEB_PORT}
|
||||
- First run: create your admin account through the onboarding wizard
|
||||
|
||||
## Manage
|
||||
@@ -322,7 +350,7 @@ MD
|
||||
docker compose up -d 2>/dev/null && log_success "Home Assistant started" || log_warning "Failed to start"
|
||||
fi
|
||||
|
||||
echo " Access at: http://localhost:8123"
|
||||
echo " Access at: http://localhost:${WEB_PORT}"
|
||||
echo " First run: open the URL and create your admin account (onboarding)."
|
||||
echo " Note: first startup can take a minute while HA initializes."
|
||||
echo ""
|
||||
|
||||
+80
-17
@@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
@@ -185,14 +200,58 @@ install_homebox() {
|
||||
require_docker || return 1
|
||||
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 INSTANCE_SUFFIX="" CONTAINER="homebox"
|
||||
local WEB_PORT="7745"
|
||||
|
||||
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 auto-scan for a free host port if this is an additional instance"
|
||||
return 0
|
||||
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"
|
||||
log_info "New instance: $HB_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Scan for a free port unconditionally — not just when adding an explicit
|
||||
# additional instance. A plain first install can just as easily collide
|
||||
# with an unrelated service that already claimed this default port — see
|
||||
# CLAUDE.md's "Port collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
mkdir -p "$HB_DIR/data"
|
||||
ensure_docker_dir_ownership "$HB_DIR"
|
||||
cd "$HB_DIR" || return 1
|
||||
@@ -221,13 +280,13 @@ networks:
|
||||
fi
|
||||
|
||||
cat > docker-compose.yml << HB_COMPOSE
|
||||
name: homebox
|
||||
name: $CONTAINER
|
||||
|
||||
services:
|
||||
homebox:
|
||||
image: ghcr.io/sysadminsmedia/homebox:latest
|
||||
container_name: homebox
|
||||
hostname: homebox
|
||||
container_name: $CONTAINER
|
||||
hostname: $CONTAINER
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- HBOX_LOG_LEVEL=info
|
||||
@@ -235,7 +294,7 @@ services:
|
||||
volumes:
|
||||
- ./data:/data
|
||||
ports:
|
||||
- "7745:7745"
|
||||
- "${WEB_PORT}:7745"
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
HB_COMPOSE
|
||||
|
||||
@@ -246,18 +305,21 @@ HB_ENV
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$HB_DIR"
|
||||
|
||||
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'
|
||||
# Homebox
|
||||
write_readme "$HB_DIR" << MD
|
||||
# Homebox${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||
|
||||
Home inventory and asset management. Track items, locations, labels,
|
||||
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
|
||||
- URL: http://localhost:7745
|
||||
- URL: http://localhost:${WEB_PORT}
|
||||
- Register your account on first visit — the first user becomes the admin.
|
||||
|
||||
## Data
|
||||
@@ -265,27 +327,28 @@ warranties, and attachments across your household.
|
||||
|
||||
## Configuration
|
||||
Key environment variables (edit docker-compose.yml to change):
|
||||
- `HBOX_LOG_LEVEL` — log verbosity (info, debug, warn, error)
|
||||
- `HBOX_WEB_MAX_UPLOAD_SIZE` — max attachment upload size in MB (default: 10)
|
||||
- \`HBOX_LOG_LEVEL\` — log verbosity (info, debug, warn, error)
|
||||
- \`HBOX_WEB_MAX_UPLOAD_SIZE\` — max attachment upload size in MB (default: 10)
|
||||
|
||||
## Manage
|
||||
```bash
|
||||
\`\`\`bash
|
||||
cd $HB_DIR
|
||||
docker compose up -d
|
||||
docker compose down
|
||||
docker compose logs -f
|
||||
docker compose pull && docker compose down && docker compose up -d
|
||||
```
|
||||
\`\`\`
|
||||
MD
|
||||
|
||||
local START_HB=""
|
||||
prompt_yn "Start Homebox now? (y/n):" "y" START_HB
|
||||
prompt_yn "Start Homebox${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} now? (y/n):" "y" START_HB
|
||||
if [ "$START_HB" = "y" ] || [ "$START_HB" = "Y" ]; then
|
||||
docker compose up -d 2>/dev/null \
|
||||
&& log_success "Homebox started" \
|
||||
&& log_success "Homebox${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)} started" \
|
||||
|| log_warning "Failed to start — check: docker compose logs"
|
||||
fi
|
||||
|
||||
echo " Access at: http://localhost:7745"
|
||||
echo " Access at: http://localhost:${WEB_PORT}"
|
||||
echo " Register your account on first visit."
|
||||
echo ""
|
||||
}
|
||||
|
||||
+91
-19
@@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
@@ -189,23 +204,77 @@ register_service immich media "Self-hosted photo & video backup — like Google
|
||||
install_immich() {
|
||||
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 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"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Immich would:"
|
||||
echo " - Create $IMMICH_DIR with docker-compose.yml + .env"
|
||||
echo " - Deploy: immich-server, immich-machine-learning, valkey, postgres"
|
||||
echo " - Offer to add a new, separate instance if one already exists"
|
||||
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 2 (external): existing photos indexed read-only, new uploads separate"
|
||||
echo " - Optionally store thumbnails/encoded-video/new-uploads in S3-compatible"
|
||||
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 " - Expose port 2283"
|
||||
echo " - Expose port 2283, auto-scanned for additional instances"
|
||||
echo " - Offer a Caddy reverse proxy and to start the stack"
|
||||
return 0
|
||||
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"
|
||||
log_info "New instance: $IMMICH_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Scan for a free port unconditionally — not just when adding an explicit
|
||||
# additional instance. A plain first install can just as easily collide
|
||||
# with an unrelated service that already claimed this default port — see
|
||||
# CLAUDE.md's "Port collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
# ── Photo library setup ─────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo " PHOTO LIBRARY SETUP"
|
||||
@@ -384,18 +453,18 @@ networks:
|
||||
"
|
||||
|
||||
cat > docker-compose.yml << IMMICH_COMPOSE
|
||||
name: immich
|
||||
name: $PROJECT
|
||||
|
||||
services:
|
||||
immich-server:
|
||||
container_name: immich_server
|
||||
container_name: $C_SERVER
|
||||
image: ghcr.io/immich-app/immich-server:\${IMMICH_VERSION:-release}
|
||||
volumes:
|
||||
${_UPLOAD_VOLUME_LINE}${_EXTERNAL_VOLUME_LINE} - /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- 2283:2283
|
||||
- ${WEB_PORT}:2283
|
||||
depends_on:
|
||||
- redis
|
||||
- database
|
||||
@@ -404,7 +473,7 @@ ${_UPLOAD_VOLUME_LINE}${_EXTERNAL_VOLUME_LINE} - /etc/localtime:/etc/localt
|
||||
disable: false
|
||||
${_CADDY_NET_BLOCK}
|
||||
immich-machine-learning:
|
||||
container_name: immich_machine_learning
|
||||
container_name: $C_ML
|
||||
image: ghcr.io/immich-app/immich-machine-learning:\${IMMICH_VERSION:-release}
|
||||
volumes:
|
||||
- model-cache:/cache
|
||||
@@ -415,14 +484,14 @@ ${_CADDY_NET_BLOCK}
|
||||
disable: false
|
||||
|
||||
redis:
|
||||
container_name: immich_redis
|
||||
container_name: $C_REDIS
|
||||
image: docker.io/valkey/valkey:9-bookworm
|
||||
healthcheck:
|
||||
test: valkey-cli ping || exit 1
|
||||
restart: always
|
||||
|
||||
database:
|
||||
container_name: immich_postgres
|
||||
container_name: $C_DB
|
||||
image: ghcr.io/immich-app/postgres:14-vectorchord0.4.3-pgvectors0.2.0
|
||||
environment:
|
||||
POSTGRES_PASSWORD: \${DB_PASSWORD}
|
||||
@@ -541,7 +610,7 @@ IMMICH_ENV
|
||||
IMPORT_HEAD
|
||||
|
||||
cat >> "$IMMICH_DIR/import-photos.sh" << IMPORT_VARS
|
||||
IMMICH_URL="http://localhost:2283"
|
||||
IMMICH_URL="http://localhost:${WEB_PORT}"
|
||||
SOURCE_DIR="$EXISTING_PHOTOS_SOURCE"
|
||||
IMMICH_DIR="$IMMICH_DIR"
|
||||
IMPORT_VARS
|
||||
@@ -791,17 +860,20 @@ IMPORT_BODY
|
||||
log_success "Import helper written: $IMMICH_DIR/import-photos.sh"
|
||||
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
|
||||
# Immich
|
||||
# Immich${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||
|
||||
Self-hosted photo and video backup — like Google Photos but private.
|
||||
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\`" )
|
||||
- App data (postgres, model cache): inside this folder
|
||||
- Edit paths/credentials in \`.env\`, then \`docker compose up -d\` to apply.
|
||||
@@ -836,8 +908,8 @@ docker compose pull && docker compose up -d # update
|
||||
\`\`\`
|
||||
|
||||
## First launch
|
||||
1. Open http://localhost:2283 and create your admin account.
|
||||
2. Install the Immich mobile app and point it at \`http://<server-ip>:2283\`.
|
||||
1. Open http://localhost:${WEB_PORT} and create your admin account.
|
||||
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,
|
||||
set import path to \`/usr/src/app/external\`, and click Scan.
|
||||
|
||||
@@ -855,13 +927,13 @@ docker compose pull && docker compose up -d # update
|
||||
MD
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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 ""
|
||||
}
|
||||
|
||||
+31
-3
@@ -47,6 +47,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
@@ -194,15 +209,22 @@ install_iopaint() {
|
||||
log_info "Installing IOPaint..."
|
||||
|
||||
local IOPAINT_DIR="$DOCKER_DIR/iopaint"
|
||||
local WEB_PORT="8100"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would create $IOPAINT_DIR"
|
||||
echo "[DRY-RUN] Would prompt for model and GPU (CUDA) support"
|
||||
echo "[DRY-RUN] Would write docker-compose.yml and .env"
|
||||
echo "[DRY-RUN] Would auto-scan for a free host port"
|
||||
echo "[DRY-RUN] Would offer Authelia SSO"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Scan for a free host port — a plain install shouldn't silently claim a
|
||||
# port another already-running service holds. See CLAUDE.md's "Port
|
||||
# collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
mkdir -p "$IOPAINT_DIR"
|
||||
ensure_docker_dir_ownership "$IOPAINT_DIR"
|
||||
cd "$IOPAINT_DIR" || return 1
|
||||
@@ -308,7 +330,7 @@ services:
|
||||
--port=8080
|
||||
--host=0.0.0.0
|
||||
ports:
|
||||
- "8100:8080"
|
||||
- "${WEB_PORT}:8080"
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./models:/root/.cache
|
||||
@@ -340,7 +362,7 @@ services:
|
||||
--port=8080
|
||||
--host=0.0.0.0
|
||||
ports:
|
||||
- "8100:8080"
|
||||
- "${WEB_PORT}:8080"
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./models:/root/.cache
|
||||
@@ -467,6 +489,12 @@ docker compose pull && docker compose down && docker compose up -d
|
||||
- input/, output/ — optional file staging
|
||||
MD
|
||||
|
||||
# The README above is a quoted (non-interpolating) heredoc — dense with
|
||||
# literal backticks for inline code spans, too risky to convert to an
|
||||
# interpolating heredoc without escaping every one of them. Patch the
|
||||
# port in afterward instead when it was scanned away from the default.
|
||||
[ "$WEB_PORT" != "8100" ] && sed -i "s/localhost:8100/localhost:${WEB_PORT}/g" "$IOPAINT_DIR/README.md"
|
||||
|
||||
local START_IO=""
|
||||
prompt_yn "Start IOPaint now? (y/n):" "y" START_IO
|
||||
if [ "$START_IO" = "y" ] || [ "$START_IO" = "Y" ]; then
|
||||
@@ -476,7 +504,7 @@ MD
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " URL: http://localhost:8100"
|
||||
echo " URL: http://localhost:${WEB_PORT}"
|
||||
echo " Model: $IOPAINT_MODEL"
|
||||
echo " Device: $DEVICE_VAL"
|
||||
echo " Switch: edit MODEL= in $IOPAINT_DIR/.env and restart"
|
||||
|
||||
+97
-16
@@ -43,6 +43,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
@@ -188,19 +203,70 @@ register_service jellyfin media "Free media server — movies, TV, music (Jellyf
|
||||
install_jellyfin() {
|
||||
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 INSTANCE_SUFFIX="" CONTAINER="jellyfin"
|
||||
local WEB_PORT="8096"
|
||||
local DEFAULT_MEDIA="$ACTUAL_HOME/media"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
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 " - 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"
|
||||
return 0
|
||||
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"
|
||||
log_info "New instance: $JELLYFIN_DIR"
|
||||
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
|
||||
|
||||
# Scan for a free port unconditionally — not just when adding an explicit
|
||||
# additional instance. A plain first install can just as easily collide
|
||||
# with an unrelated service that already claimed this default port (e.g.
|
||||
# emby also defaults to 8096) — see CLAUDE.md's "Port collision avoidance"
|
||||
# section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
local MEDIA_PATH=""
|
||||
prompt_text "Path to media folder [$DEFAULT_MEDIA]:" "$DEFAULT_MEDIA" MEDIA_PATH
|
||||
MEDIA_PATH="${MEDIA_PATH/#\~/$ACTUAL_HOME}"; MEDIA_PATH="${MEDIA_PATH%/}"
|
||||
@@ -248,14 +314,25 @@ networks:
|
||||
"
|
||||
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
|
||||
name: jellyfin
|
||||
name: $CONTAINER
|
||||
|
||||
services:
|
||||
jellyfin:
|
||||
image: jellyfin/jellyfin:latest
|
||||
container_name: jellyfin
|
||||
hostname: jellyfin
|
||||
container_name: $CONTAINER
|
||||
hostname: $CONTAINER
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TZ=$TZ_VAL
|
||||
@@ -265,10 +342,8 @@ $HWACCEL_BLOCK
|
||||
- ./cache:/cache
|
||||
- \${MEDIA_PATH}:/media:ro
|
||||
ports:
|
||||
- "8096:8096"
|
||||
- "1900:1900/udp"
|
||||
- "7359:7359/udp"
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
- "${WEB_PORT}:8096"
|
||||
${_DISCOVERY_PORTS}${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
JELLYFIN_COMPOSE
|
||||
|
||||
cat > .env << JELLYFIN_ENV
|
||||
@@ -278,16 +353,22 @@ JELLYFIN_ENV
|
||||
|
||||
mkdir -p config cache
|
||||
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
|
||||
# Jellyfin
|
||||
# Jellyfin${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||
|
||||
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
|
||||
- App data: \`config/\` and \`cache/\` in this folder
|
||||
- Edit the media path in \`.env\` (\`MEDIA_PATH=\`), then \`docker compose up -d\`.
|
||||
@@ -309,13 +390,13 @@ docker compose pull && docker compose up -d # update
|
||||
MD
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
echo ""
|
||||
echo " Access at: http://localhost:8096"
|
||||
echo " Access at: http://localhost:${WEB_PORT}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
|
||||
+83
-16
@@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
@@ -185,14 +200,63 @@ install_joplin() {
|
||||
require_docker || return 1
|
||||
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 INSTANCE_SUFFIX="" CONTAINER="joplin" DB_CONTAINER="joplin-db"
|
||||
local WEB_PORT="22300"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would create $JOPLIN_DIR"
|
||||
echo "[DRY-RUN] Would write docker-compose.yml and .env"
|
||||
echo "[DRY-RUN] Would offer to add a new, separate instance if one already exists"
|
||||
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
|
||||
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"
|
||||
log_info "New instance: $JOPLIN_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Scan for a free port unconditionally — not just when adding an explicit
|
||||
# additional instance. A plain first install can just as easily collide
|
||||
# with an unrelated service that already claimed this default port — see
|
||||
# CLAUDE.md's "Port collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
mkdir -p "$JOPLIN_DIR"
|
||||
ensure_docker_dir_ownership "$JOPLIN_DIR"
|
||||
cd "$JOPLIN_DIR" || return 1
|
||||
@@ -203,7 +267,7 @@ install_joplin() {
|
||||
local DB_PASS=""
|
||||
[ -f ".env" ] && DB_PASS="$(grep '^POSTGRES_PASSWORD=' .env | cut -d= -f2-)"
|
||||
[ -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):
|
||||
# explicit CADDY_MODE from the site config wins, then a local ~/docker/caddy,
|
||||
@@ -229,24 +293,24 @@ networks:
|
||||
fi
|
||||
|
||||
cat > docker-compose.yml << JOPLIN_COMPOSE
|
||||
name: joplin
|
||||
name: $CONTAINER
|
||||
|
||||
services:
|
||||
joplin:
|
||||
image: joplin/server:latest
|
||||
container_name: joplin
|
||||
hostname: joplin
|
||||
container_name: $CONTAINER
|
||||
hostname: $CONTAINER
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- joplin-db
|
||||
env_file: .env
|
||||
ports:
|
||||
- "22300:22300"
|
||||
- "${WEB_PORT}:22300"
|
||||
${_CADDY_NET_BLOCK}
|
||||
joplin-db:
|
||||
image: postgres:15-alpine
|
||||
container_name: joplin-db
|
||||
hostname: joplin-db
|
||||
container_name: $DB_CONTAINER
|
||||
hostname: $DB_CONTAINER
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
volumes:
|
||||
@@ -278,19 +342,22 @@ JOPLIN_ENV
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JOPLIN_DIR"
|
||||
|
||||
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_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
|
||||
# Joplin Server
|
||||
# Joplin Server${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||
|
||||
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
|
||||
- URL: http://localhost:22300
|
||||
- URL: http://localhost:${WEB_PORT}
|
||||
- Default admin: admin@localhost / admin (change immediately after first login!)
|
||||
|
||||
## Important
|
||||
@@ -319,14 +386,14 @@ docker compose pull && docker compose down && docker compose up -d # update
|
||||
MD
|
||||
|
||||
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
|
||||
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"
|
||||
fi
|
||||
|
||||
echo " Access at: http://localhost:22300"
|
||||
echo " Access at: http://localhost:${WEB_PORT}"
|
||||
echo " Default login: admin@localhost / admin (change immediately!)"
|
||||
echo ""
|
||||
}
|
||||
|
||||
+41
-15
@@ -45,6 +45,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
generate_password() {
|
||||
local _len="${1:-32}"
|
||||
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
|
||||
@@ -196,6 +211,17 @@ install_koha() {
|
||||
require_docker || return 1
|
||||
|
||||
local KOHA_DIR="$DOCKER_DIR/koha"
|
||||
local OPAC_PORT="8097" STAFF_PORT="8098"
|
||||
|
||||
# Scan for free host ports, moving both together — a plain install
|
||||
# shouldn't silently claim a port another already-running service holds.
|
||||
# Run before any prompts so every message below (including DRY-RUN)
|
||||
# reflects the real port. See CLAUDE.md's "Port collision avoidance"
|
||||
# section.
|
||||
while port_in_use "$OPAC_PORT" || port_in_use "$STAFF_PORT"; do
|
||||
OPAC_PORT=$((OPAC_PORT + 1))
|
||||
STAFF_PORT=$((STAFF_PORT + 1))
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "╔═══════════════════════════════════════════════════════════════════╗"
|
||||
@@ -211,7 +237,7 @@ install_koha() {
|
||||
echo " SETUP OVERVIEW:"
|
||||
echo " 1) Answer the questions below (collects library details + credentials)"
|
||||
echo " 2) Koha starts — takes 2–3 min on first boot"
|
||||
echo " 3) Open http://localhost:8098 and complete the brief web installer (~2 min)"
|
||||
echo " 3) Open http://localhost:${STAFF_PORT} and complete the brief web installer (~2 min)"
|
||||
echo " 4) Run $KOHA_DIR/post-setup.sh to auto-configure library, items, locations"
|
||||
echo ""
|
||||
|
||||
@@ -219,8 +245,8 @@ install_koha() {
|
||||
echo "[DRY-RUN] Would create $KOHA_DIR with docker-compose.yml + config-main.env"
|
||||
echo "[DRY-RUN] Would deploy teogramm/koha + MariaDB (./data/) + Memcached + RabbitMQ"
|
||||
echo "[DRY-RUN] Would generate post-setup.sh for REST API configuration"
|
||||
echo "[DRY-RUN] OPAC (patron UI): port 8097"
|
||||
echo "[DRY-RUN] Staff/admin: port 8098"
|
||||
echo "[DRY-RUN] OPAC (patron UI): port $OPAC_PORT"
|
||||
echo "[DRY-RUN] Staff/admin: port $STAFF_PORT"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -399,8 +425,8 @@ services:
|
||||
env_file:
|
||||
- config-main.env
|
||||
ports:
|
||||
- "8097:8080"
|
||||
- "8098:8081"
|
||||
- "${OPAC_PORT}:8080"
|
||||
- "${STAFF_PORT}:8081"
|
||||
depends_on:
|
||||
- koha-db
|
||||
- koha-memcached
|
||||
@@ -485,13 +511,13 @@ KOHA_ENV
|
||||
|
||||
cat > post-setup.sh << POSTSETUP
|
||||
#!/bin/bash
|
||||
# post-setup.sh — Run AFTER completing the Koha web installer at http://localhost:8098
|
||||
# post-setup.sh — Run AFTER completing the Koha web installer at http://localhost:${STAFF_PORT}
|
||||
# Configures library, item types, and shelf locations via the Koha REST API.
|
||||
# Generated by ubuntu-post-install on $(date '+%F').
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
KOHA_STAFF_URL="http://localhost:8098"
|
||||
KOHA_STAFF_URL="http://localhost:${STAFF_PORT}"
|
||||
KOHA_DIR="${KOHA_DIR}"
|
||||
CONF="\${KOHA_DIR}/config-main.env"
|
||||
|
||||
@@ -627,8 +653,8 @@ echo "════════════════════════
|
||||
echo ""
|
||||
echo " Your Koha library is ready to use."
|
||||
echo ""
|
||||
echo " OPAC (patron browsing): http://localhost:8097"
|
||||
echo " Staff / admin: http://localhost:8098"
|
||||
echo " OPAC (patron browsing): http://localhost:${OPAC_PORT}"
|
||||
echo " Staff / admin: http://localhost:${STAFF_PORT}"
|
||||
echo ""
|
||||
echo " Next steps in Staff UI:"
|
||||
echo " 1. Administration → Patron categories → add patron types"
|
||||
@@ -658,8 +684,8 @@ POSTSETUP
|
||||
Full Integrated Library System for managing a physical book collection.
|
||||
|
||||
## Access
|
||||
- **OPAC** (patron browsing): http://localhost:8097
|
||||
- **Staff / admin**: http://localhost:8098
|
||||
- **OPAC** (patron browsing): http://localhost:${OPAC_PORT}
|
||||
- **Staff / admin**: http://localhost:${STAFF_PORT}
|
||||
|
||||
## Quick setup (4 steps)
|
||||
|
||||
@@ -671,7 +697,7 @@ docker compose up -d
|
||||
Takes 2–3 minutes on first boot while Koha initialises.
|
||||
|
||||
### Step 2 — Complete the web installer
|
||||
1. Open **http://localhost:8098**
|
||||
1. Open **http://localhost:${STAFF_PORT}**
|
||||
2. You may see a "Database connection" page first — wait 1–2 min and refresh
|
||||
3. The installer wizard appears automatically:
|
||||
- **Language**: click "Install for language English" → Continue
|
||||
@@ -743,12 +769,12 @@ MD
|
||||
echo " └──────────────────────────────────────────────────────────────┘"
|
||||
echo ""
|
||||
echo " NEXT STEPS:"
|
||||
echo " 1. Wait ~3 min, then open: http://localhost:8098"
|
||||
echo " 1. Wait ~3 min, then open: http://localhost:${STAFF_PORT}"
|
||||
echo " 2. Complete the web installer (use password above when asked)"
|
||||
echo " 3. Run: sudo $KOHA_DIR/post-setup.sh"
|
||||
echo ""
|
||||
echo " OPAC (patron UI): http://localhost:8097"
|
||||
echo " Staff / admin: http://localhost:8098"
|
||||
echo " OPAC (patron UI): http://localhost:${OPAC_PORT}"
|
||||
echo " Staff / admin: http://localhost:${STAFF_PORT}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
|
||||
+142
-21
@@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
@@ -196,19 +211,96 @@ register_service lyrion media "Music streaming server — Squeezebox, Chromecast
|
||||
install_lyrion() {
|
||||
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 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"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
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 " - Run with network_mode: host (required for Chromecast/Squeezebox UDP discovery)"
|
||||
echo " - Expose port 9000 (web), 9090 (CLI), 3483 (players)"
|
||||
echo " - First instance: network_mode: host (Chromecast/Squeezebox UDP discovery)"
|
||||
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"
|
||||
return 0
|
||||
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."
|
||||
log_info "New instance: $LYRION_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Port collision avoidance — see CLAUDE.md's "Port collision avoidance"
|
||||
# section. Bridge-mode instances can fully auto-scan (all 3 ports move
|
||||
# together). Host-mode (the first instance) can only auto-adjust
|
||||
# WEB_PORT — HTTP_PORT is a real env var the image honors even under
|
||||
# host networking — CLI_PORT/PLAYER_PORT are hardcoded inside the image
|
||||
# with no override, so a collision there can only be warned about, not
|
||||
# silently fixed.
|
||||
if [ "$USE_HOST_NETWORK" = true ]; then
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
port_in_use "$CLI_PORT" && log_warning "CLI port $CLI_PORT is already in use by another process — Lyrion's CLI interface won't be reachable until that's resolved (this port isn't configurable in the image)."
|
||||
port_in_use "$PLAYER_PORT" && log_warning "Player port $PLAYER_PORT is already in use by another process — Squeezebox/app connections won't work until that's resolved (this port isn't configurable in the image)."
|
||||
else
|
||||
while port_in_use "$WEB_PORT" || port_in_use "$CLI_PORT" || port_in_use "$PLAYER_PORT"; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
CLI_PORT=$((CLI_PORT + 1))
|
||||
PLAYER_PORT=$((PLAYER_PORT + 1))
|
||||
done
|
||||
fi
|
||||
|
||||
local MUSIC_PATH=""
|
||||
prompt_text "Path to music folder [$DEFAULT_MUSIC]:" "$DEFAULT_MUSIC" MUSIC_PATH
|
||||
MUSIC_PATH="${MUSIC_PATH/#\~/$ACTUAL_HOME}"; MUSIC_PATH="${MUSIC_PATH%/}"
|
||||
@@ -221,18 +313,35 @@ install_lyrion() {
|
||||
TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
||||
UID_VAL=$(id -u "$ACTUAL_USER"); GID_VAL=$(id -g "$ACTUAL_USER")
|
||||
|
||||
# Host mode has no ports: remapping — HTTP_PORT is the only thing that
|
||||
# actually controls the bind port, so it must track the scanned WEB_PORT.
|
||||
# Bridge mode keeps the container's internal port fixed at 9000 and lets
|
||||
# the ports: line do the remapping instead.
|
||||
local _NETWORK_BLOCK=" network_mode: host
|
||||
"
|
||||
local _PORTS_BLOCK=""
|
||||
local _HTTP_PORT_INTERNAL="$WEB_PORT"
|
||||
if [ "$USE_HOST_NETWORK" != true ]; then
|
||||
_NETWORK_BLOCK=""
|
||||
_PORTS_BLOCK=" ports:
|
||||
- \"${WEB_PORT}:9000\"
|
||||
- \"${CLI_PORT}:9090\"
|
||||
- \"${PLAYER_PORT}:3483\"
|
||||
"
|
||||
_HTTP_PORT_INTERNAL="9000"
|
||||
fi
|
||||
|
||||
cat > docker-compose.yml << LYRION_COMPOSE
|
||||
name: lyrion
|
||||
name: $CONTAINER
|
||||
|
||||
services:
|
||||
lyrion:
|
||||
image: lmscommunity/lyrionmusicserver:stable
|
||||
container_name: lyrion
|
||||
hostname: lyrion
|
||||
container_name: $CONTAINER
|
||||
hostname: $CONTAINER
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
environment:
|
||||
- HTTP_PORT=9000
|
||||
${_NETWORK_BLOCK}${_PORTS_BLOCK} environment:
|
||||
- HTTP_PORT=$_HTTP_PORT_INTERNAL
|
||||
- PUID=$UID_VAL
|
||||
- PGID=$GID_VAL
|
||||
- TZ=$TZ_VAL
|
||||
@@ -251,19 +360,26 @@ LYRION_ENV
|
||||
|
||||
mkdir -p config playlists
|
||||
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
|
||||
# Lyrion Music Server
|
||||
# Lyrion Music Server${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||
|
||||
Stream music to Squeezebox devices, the Squeezer Android/iOS app, and Chromecast.
|
||||
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
|
||||
- Player port: 3483 (Squeezeboxes / apps)
|
||||
- CLI port: 9090
|
||||
- Web UI: http://localhost:${WEB_PORT}
|
||||
- Player port: ${PLAYER_PORT} (Squeezeboxes / apps)
|
||||
- CLI port: ${CLI_PORT}
|
||||
- Music folder (read-only): \`$MUSIC_PATH\` → mounted at /music
|
||||
- App data: \`config/\` and \`playlists/\`
|
||||
|
||||
@@ -277,21 +393,26 @@ docker compose pull && docker compose up -d # update
|
||||
\`\`\`
|
||||
|
||||
## Notes
|
||||
- Uses \`network_mode: host\` so UDP discovery for Chromecast and Squeezebox devices
|
||||
works without manual port mapping.
|
||||
$( [ "$USE_HOST_NETWORK" = true ] && echo "- Uses \`network_mode: host\` so UDP discovery for Chromecast and Squeezebox devices
|
||||
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\`.
|
||||
- Add music libraries in the web UI under Settings → Music Library.
|
||||
MD
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
echo ""
|
||||
echo " Access at: http://localhost:9000"
|
||||
echo " Note: uses host networking for Chromecast/Squeezebox UDP discovery"
|
||||
echo " Access at: http://localhost:${WEB_PORT}"
|
||||
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 ""
|
||||
}
|
||||
|
||||
|
||||
+30
-16
@@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
@@ -251,25 +266,24 @@ install_mattermost() {
|
||||
MM_CONTAINER="mattermost-$_suffix"
|
||||
DB_CONTAINER="mattermost-$_suffix-db"
|
||||
COTURN_CONSUMER="mattermost-$_suffix"
|
||||
|
||||
# Free-port scan — same pattern services/asterisk.sh uses for its
|
||||
# web admin port. WEB_PORT is also set as Mattermost's own
|
||||
# internal ListenAddress below (not just the host publish side),
|
||||
# so configure_caddy_for_service's single upstream "name:port"
|
||||
# string works unmodified in both local and remote-Caddy mode —
|
||||
# it assumes host-published-port == container-internal-port,
|
||||
# true for every other service in this repo and made true here
|
||||
# too rather than special-casing the shared helper for one caller.
|
||||
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
done
|
||||
while ss -ulnH "sport = :${CALLS_UDP_PORT}" 2>/dev/null | grep -q .; do
|
||||
CALLS_UDP_PORT=$((CALLS_UDP_PORT + 1))
|
||||
done
|
||||
log_info "New instance: $DIR (web port $WEB_PORT, Calls UDP port $CALLS_UDP_PORT)"
|
||||
log_info "New instance: $DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Free-port scan — runs unconditionally, not just when adding an explicit
|
||||
# additional instance, so a plain first install also can't collide with
|
||||
# an unrelated service that already claimed these default ports. Same
|
||||
# pattern services/asterisk.sh uses for its web admin port. WEB_PORT is
|
||||
# also set as Mattermost's own internal ListenAddress below (not just the
|
||||
# host publish side), so configure_caddy_for_service's single upstream
|
||||
# "name:port" string works unmodified in both local and remote-Caddy mode —
|
||||
# it assumes host-published-port == container-internal-port, true for
|
||||
# every other service in this repo and made true here too rather than
|
||||
# special-casing the shared helper for one caller. See CLAUDE.md's "Port
|
||||
# collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
find_free_port CALLS_UDP_PORT "$CALLS_UDP_PORT" udp
|
||||
|
||||
log_info "Installing Mattermost${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}..."
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
|
||||
+22
-5
@@ -41,6 +41,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
@@ -230,14 +245,16 @@ install_mealie() {
|
||||
INSTANCE_SUFFIX="$_suffix"
|
||||
MEALIE_DIR="$DOCKER_DIR/mealie-$_suffix"
|
||||
CONTAINER="mealie-$_suffix"
|
||||
|
||||
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
done
|
||||
log_info "New instance: $MEALIE_DIR (port $WEB_PORT)"
|
||||
log_info "New instance: $MEALIE_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Scan for a free port unconditionally — not just when adding an explicit
|
||||
# additional instance. A plain first install can just as easily collide
|
||||
# with an unrelated service that already claimed this default port — see
|
||||
# CLAUDE.md's "Port collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
mkdir -p "$MEALIE_DIR"
|
||||
ensure_docker_dir_ownership "$MEALIE_DIR"
|
||||
cd "$MEALIE_DIR" || return 1
|
||||
|
||||
+83
-17
@@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
@@ -196,17 +211,65 @@ register_service meshcentral utilities "Self-hosted remote device management ser
|
||||
install_meshcentral() {
|
||||
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 INSTANCE_SUFFIX="" CONTAINER="meshcentral"
|
||||
local WEB_PORT="4430" AGENT_PORT="4433"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
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 " - 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"
|
||||
return 0
|
||||
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"
|
||||
log_info "New instance: $MC_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Scan for free ports unconditionally, moving both together — not just
|
||||
# when adding an explicit additional instance. A plain first install can
|
||||
# just as easily collide with an unrelated service that already claimed
|
||||
# one of these default ports — see CLAUDE.md's "Port collision
|
||||
# avoidance" section.
|
||||
while port_in_use "$WEB_PORT" || port_in_use "$AGENT_PORT"; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
AGENT_PORT=$((AGENT_PORT + 1))
|
||||
done
|
||||
|
||||
local MC_HOSTNAME=""
|
||||
prompt_text "MeshCentral hostname (domain or IP) [localhost]:" "localhost" MC_HOSTNAME
|
||||
MC_HOSTNAME="${MC_HOSTNAME:-localhost}"
|
||||
@@ -239,13 +302,13 @@ networks:
|
||||
fi
|
||||
|
||||
cat > docker-compose.yml << MC_COMPOSE
|
||||
name: meshcentral
|
||||
name: $CONTAINER
|
||||
|
||||
services:
|
||||
meshcentral:
|
||||
image: ghcr.io/ylianst/meshcentral:latest
|
||||
container_name: meshcentral
|
||||
hostname: meshcentral
|
||||
container_name: $CONTAINER
|
||||
hostname: $CONTAINER
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
@@ -260,8 +323,8 @@ services:
|
||||
- ./files:/opt/meshcentral/meshcentral-files
|
||||
- ./backups:/opt/meshcentral/meshcentral-backups
|
||||
ports:
|
||||
- "4430:443"
|
||||
- "4433:4433"
|
||||
- "${WEB_PORT}:443"
|
||||
- "${AGENT_PORT}:4433"
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
MC_COMPOSE
|
||||
|
||||
@@ -274,18 +337,21 @@ MC_ENV
|
||||
|
||||
mkdir -p data files backups
|
||||
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
|
||||
# MeshCentral
|
||||
# MeshCentral${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||
|
||||
Self-hosted remote device management — remotely access, manage, and monitor
|
||||
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)
|
||||
- Agent listener: port 4433 (devices connect here — forward this port if remote)
|
||||
- Web UI: https://localhost:${WEB_PORT} (self-signed cert on first launch)
|
||||
- Agent listener: port ${AGENT_PORT} (devices connect here — forward this port if remote)
|
||||
- Hostname: \`$MC_HOSTNAME\` (update \`MC_HOSTNAME\` in .env if it changes)
|
||||
- App data: \`data/\`, \`files/\`, \`backups/\`
|
||||
|
||||
@@ -299,14 +365,14 @@ docker compose pull && docker compose up -d # update
|
||||
\`\`\`
|
||||
|
||||
## 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
|
||||
3. Go to "My Devices" → "+ Add Device" → download the agent for each OS
|
||||
4. Install the agent on every computer you want to manage
|
||||
|
||||
## Remote access
|
||||
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
|
||||
|
||||
## Docs
|
||||
@@ -314,13 +380,13 @@ https://meshcentral.com/docs/
|
||||
MD
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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 ""
|
||||
}
|
||||
|
||||
+30
-8
@@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
@@ -186,13 +201,20 @@ install_n8n() {
|
||||
log_info "Installing n8n..."
|
||||
|
||||
local N8N_DIR="$DOCKER_DIR/n8n"
|
||||
local WEB_PORT="5678"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would create $N8N_DIR"
|
||||
echo "[DRY-RUN] Would write docker-compose.yml and .env"
|
||||
echo "[DRY-RUN] Would auto-scan for a free host port"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Scan for a free host port — a plain install shouldn't silently claim a
|
||||
# port another already-running service holds. See CLAUDE.md's "Port
|
||||
# collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
mkdir -p "$N8N_DIR/data"
|
||||
ensure_docker_dir_ownership "$N8N_DIR"
|
||||
cd "$N8N_DIR" || return 1
|
||||
@@ -230,7 +252,7 @@ services:
|
||||
hostname: n8n
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5678:5678"
|
||||
- "${WEB_PORT}:5678"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
@@ -260,27 +282,27 @@ N8N_ENV
|
||||
|
||||
configure_caddy_for_service "n8n" "n8n:5678" "n8n"
|
||||
|
||||
write_readme "$N8N_DIR" << 'MD'
|
||||
write_readme "$N8N_DIR" << MD
|
||||
# n8n
|
||||
|
||||
Workflow automation platform — connect all your self-hosted services with
|
||||
a visual editor. Create webhooks, scheduled jobs, and multi-step automations.
|
||||
|
||||
## Access
|
||||
- URL: http://localhost:5678
|
||||
- URL: http://localhost:${WEB_PORT}
|
||||
- On first run, n8n prompts you to create an owner account.
|
||||
|
||||
## Manage
|
||||
```bash
|
||||
\`\`\`bash
|
||||
docker compose up -d # start
|
||||
docker compose down # stop
|
||||
docker compose logs -f # logs
|
||||
docker compose pull && docker compose down && docker compose up -d # update
|
||||
```
|
||||
\`\`\`
|
||||
|
||||
## Environment
|
||||
Edit `.env` to change `WEBHOOK_URL` or `N8N_HOST` after deployment,
|
||||
then restart: `docker compose down && docker compose up -d`
|
||||
Edit \`.env\` to change \`WEBHOOK_URL\` or \`N8N_HOST\` after deployment,
|
||||
then restart: \`docker compose down && docker compose up -d\`
|
||||
MD
|
||||
|
||||
local START_N8N=""
|
||||
@@ -291,7 +313,7 @@ MD
|
||||
|| log_warning "Failed to start — check: docker compose logs"
|
||||
fi
|
||||
|
||||
echo " Access at: http://localhost:5678"
|
||||
echo " Access at: http://localhost:${WEB_PORT}"
|
||||
echo " Create your owner account on first visit."
|
||||
echo ""
|
||||
}
|
||||
|
||||
+26
-3
@@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
generate_password() {
|
||||
local _len="${1:-32}"
|
||||
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
|
||||
@@ -180,12 +195,20 @@ install_nextcloud() {
|
||||
require_docker || return 1
|
||||
log_info "Installing Nextcloud..."
|
||||
local DIR="$DOCKER_DIR/nextcloud"
|
||||
local WEB_PORT="8080"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would create $DIR with Dockerfile, docker-compose.yml, .env"
|
||||
echo "[DRY-RUN] Would auto-scan for a free host port (8080 default)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Scan for a free host port — this default (8080) isn't unique to
|
||||
# Nextcloud in this repo (arm also defaults to 8080), so a plain install
|
||||
# shouldn't silently claim a port another already-running service holds.
|
||||
# See CLAUDE.md's "Port collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
mkdir -p "$DIR"
|
||||
ensure_docker_dir_ownership "$DIR"
|
||||
cd "$DIR" || return 1
|
||||
@@ -253,7 +276,7 @@ services:
|
||||
- ./config:/var/www/html/config
|
||||
- ./custom_apps:/var/www/html/custom_apps
|
||||
ports:
|
||||
- "8080:80"
|
||||
- "${WEB_PORT}:80"
|
||||
${_CADDY_NET_BLOCK}
|
||||
db:
|
||||
image: mariadb:10.11
|
||||
@@ -328,7 +351,7 @@ Self-hosted cloud storage with SMB/local file access.
|
||||
|
||||
## Access
|
||||
|
||||
- URL: https://cloud.${SITE_DOMAIN:-example.com} (or http://localhost:8080)
|
||||
- URL: https://cloud.${SITE_DOMAIN:-example.com} (or http://localhost:${WEB_PORT})
|
||||
- Admin: admin
|
||||
- Password: see \`NEXTCLOUD_ADMIN_PASSWORD\` in \`$DIR/.env\`
|
||||
|
||||
@@ -366,7 +389,7 @@ Back up these directories:
|
||||
NCREADME
|
||||
|
||||
echo ""
|
||||
echo " Access URL: http://localhost:8080"
|
||||
echo " Access URL: http://localhost:${WEB_PORT}"
|
||||
echo " Admin user: admin"
|
||||
echo " Admin pass: $NC_ADMIN_PASS"
|
||||
echo " Config dir: $DIR"
|
||||
|
||||
+81
-17
@@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
@@ -192,13 +207,59 @@ register_service ntfy utilities "Self-hosted push notifications (ntfy)" 8090
|
||||
install_ntfy() {
|
||||
require_docker || return 1
|
||||
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 INSTANCE_SUFFIX="" CONTAINER="ntfy"
|
||||
local WEB_PORT="8090"
|
||||
|
||||
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
|
||||
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"
|
||||
log_info "New instance: $NTFY_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Scan for a free port unconditionally — not just when adding an explicit
|
||||
# additional instance. A plain first install can just as easily collide
|
||||
# with an unrelated service that already claimed this default port — see
|
||||
# CLAUDE.md's "Port collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
mkdir -p "$NTFY_DIR"
|
||||
ensure_docker_dir_ownership "$NTFY_DIR"
|
||||
cd "$NTFY_DIR" || return 1
|
||||
@@ -227,13 +288,13 @@ networks:
|
||||
fi
|
||||
|
||||
cat > docker-compose.yml << NTFY_COMPOSE
|
||||
name: ntfy
|
||||
name: $CONTAINER
|
||||
|
||||
services:
|
||||
ntfy:
|
||||
image: binwiederhier/ntfy:latest
|
||||
container_name: ntfy
|
||||
hostname: ntfy
|
||||
container_name: $CONTAINER
|
||||
hostname: $CONTAINER
|
||||
restart: unless-stopped
|
||||
command: serve
|
||||
environment:
|
||||
@@ -242,7 +303,7 @@ services:
|
||||
- ./cache:/var/cache/ntfy
|
||||
- ./config:/etc/ntfy
|
||||
ports:
|
||||
- "8090:80"
|
||||
- "${WEB_PORT}:80"
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
NTFY_COMPOSE
|
||||
|
||||
@@ -258,7 +319,7 @@ NTFY_ENV
|
||||
if [ ! -f config/server.yml ]; then
|
||||
local NTFY_BASE_URL=""
|
||||
if [ -n "$SITE_DOMAIN" ] && [ "$SITE_DOMAIN" != "example.com" ]; then
|
||||
NTFY_BASE_URL="https://ntfy.${SITE_DOMAIN}"
|
||||
NTFY_BASE_URL="https://ntfy${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}.${SITE_DOMAIN}"
|
||||
fi
|
||||
cat > config/server.yml << NTFY_CFG
|
||||
# ntfy server configuration — https://docs.ntfy.sh/config/
|
||||
@@ -291,22 +352,25 @@ NTFY_CFG
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$NTFY_DIR"
|
||||
|
||||
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
|
||||
# ntfy
|
||||
# ntfy${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||
|
||||
Self-hosted push notification server. Send notifications from scripts to your
|
||||
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
|
||||
- URL: http://localhost:8090
|
||||
- URL: http://localhost:${WEB_PORT}
|
||||
|
||||
## Usage
|
||||
- Send a notification: \`curl -d "Hello!" localhost:8090/mytopic\`
|
||||
- Subscribe on phone: ntfy app -> Add subscription -> localhost:8090/mytopic
|
||||
- Send a notification: \`curl -d "Hello!" localhost:${WEB_PORT}/mytopic\`
|
||||
- Subscribe on phone: ntfy app -> Add subscription -> localhost:${WEB_PORT}/mytopic
|
||||
|
||||
## Access model
|
||||
\`auth-default-access: read-write\` — anyone who knows a topic name can read
|
||||
@@ -335,15 +399,15 @@ docker compose logs -f # logs
|
||||
MD
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
echo " Access at: http://localhost:8090"
|
||||
echo " Access at: http://localhost:${WEB_PORT}"
|
||||
echo ""
|
||||
echo " Send notification: curl -d \"Hello!\" localhost:8090/mytopic"
|
||||
echo " Subscribe on phone: ntfy app → Add subscription → localhost:8090/mytopic"
|
||||
echo " Send notification: curl -d \"Hello!\" localhost:${WEB_PORT}/mytopic"
|
||||
echo " Subscribe on phone: ntfy app → Add subscription → localhost:${WEB_PORT}/mytopic"
|
||||
echo ""
|
||||
}
|
||||
|
||||
|
||||
+25
-3
@@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
generate_password() {
|
||||
local _len="${1:-32}"
|
||||
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
|
||||
@@ -226,12 +241,19 @@ install_onlyoffice() {
|
||||
|
||||
log_info "Installing OnlyOffice Document Server..."
|
||||
local DIR="$DOCKER_DIR/onlyoffice"
|
||||
local WEB_PORT="8082"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would create $DIR with docker-compose.yml, .env"
|
||||
echo "[DRY-RUN] Would auto-scan for a free host port (8082 default)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Scan for a free host port — a plain install shouldn't silently claim a
|
||||
# port another already-running service holds. See CLAUDE.md's "Port
|
||||
# collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
mkdir -p "$DIR"
|
||||
ensure_docker_dir_ownership "$DIR"
|
||||
cd "$DIR" || return 1
|
||||
@@ -281,7 +303,7 @@ services:
|
||||
- ./data:/var/www/onlyoffice/Data
|
||||
- ./fonts:/usr/share/fonts/truetype/custom
|
||||
ports:
|
||||
- "8082:80"
|
||||
- "${WEB_PORT}:80"
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
OOCOMPOSE
|
||||
|
||||
@@ -333,7 +355,7 @@ Self-hosted document editing server, integrated with Nextcloud and FileBrowser Q
|
||||
|
||||
## Access
|
||||
|
||||
- URL: https://office.${SITE_DOMAIN:-example.com} (or http://localhost:8082)
|
||||
- URL: https://office.${SITE_DOMAIN:-example.com} (or http://localhost:${WEB_PORT})
|
||||
- The document server itself has no user-facing login page — it is accessed
|
||||
through Nextcloud or FileBrowser Quantum.
|
||||
|
||||
@@ -381,7 +403,7 @@ OOREAD
|
||||
|
||||
echo ""
|
||||
echo " OnlyOffice Document Server"
|
||||
echo " Access URL: http://localhost:8082"
|
||||
echo " Access URL: http://localhost:${WEB_PORT}"
|
||||
echo " JWT secret: $JWT_SECRET"
|
||||
echo " Config dir: $DIR"
|
||||
echo ""
|
||||
|
||||
+31
-6
@@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
@@ -184,12 +199,22 @@ install_portainer() {
|
||||
require_docker || return 1
|
||||
log_info "Installing Portainer..."
|
||||
local PORTAINER_DIR="$DOCKER_DIR/portainer"
|
||||
local HTTP_PORT="9000" HTTPS_PORT="9443"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would create $PORTAINER_DIR"
|
||||
echo "[DRY-RUN] Would auto-scan for free host ports (9000/9443 defaults)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Scan for free host ports, moving both together — a plain install
|
||||
# shouldn't silently claim a port another already-running service holds.
|
||||
# See CLAUDE.md's "Port collision avoidance" section.
|
||||
while port_in_use "$HTTP_PORT" || port_in_use "$HTTPS_PORT"; do
|
||||
HTTP_PORT=$((HTTP_PORT + 1))
|
||||
HTTPS_PORT=$((HTTPS_PORT + 1))
|
||||
done
|
||||
|
||||
mkdir -p "$PORTAINER_DIR"
|
||||
ensure_docker_dir_ownership "$PORTAINER_DIR"
|
||||
cd "$PORTAINER_DIR" || return 1
|
||||
@@ -230,8 +255,8 @@ services:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./data:/data
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9443:9443"
|
||||
- "${HTTP_PORT}:9000"
|
||||
- "${HTTPS_PORT}:9443"
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
PORTAINER_COMPOSE
|
||||
|
||||
@@ -239,7 +264,7 @@ PORTAINER_COMPOSE
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$PORTAINER_DIR"
|
||||
|
||||
echo ""
|
||||
log_success "Portainer configured at $PORTAINER_DIR"
|
||||
log_success "Portainer configured at $PORTAINER_DIR (HTTP $HTTP_PORT, HTTPS $HTTPS_PORT)"
|
||||
|
||||
configure_caddy_for_service "Portainer" "portainer:9000" "portainer"
|
||||
|
||||
@@ -249,8 +274,8 @@ PORTAINER_COMPOSE
|
||||
Web UI for managing Docker — containers, images, volumes, and networks.
|
||||
|
||||
## Access
|
||||
- HTTPS: https://localhost:9443
|
||||
- HTTP: http://localhost:9000
|
||||
- HTTPS: https://localhost:${HTTPS_PORT}
|
||||
- HTTP: http://localhost:${HTTP_PORT}
|
||||
- Create your admin account on first visit.
|
||||
|
||||
## Data
|
||||
@@ -272,7 +297,7 @@ MD
|
||||
docker compose up -d 2>/dev/null && log_success "Portainer started" || log_warning "Failed to start"
|
||||
fi
|
||||
|
||||
echo " Access at: https://localhost:9443"
|
||||
echo " Access at: https://localhost:${HTTPS_PORT}"
|
||||
echo " Create admin account on first visit"
|
||||
echo ""
|
||||
}
|
||||
|
||||
+103
-26
@@ -56,6 +56,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
@@ -100,21 +115,80 @@ register_service rustdesk utilities "Self-hosted remote desktop relay (RustDesk)
|
||||
install_rustdesk() {
|
||||
require_docker || return 1
|
||||
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 INSTANCE_SUFFIX="" CONTAINER="rustdesk"
|
||||
local PORT_OFFSET=0
|
||||
|
||||
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] 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)"
|
||||
return 0
|
||||
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"
|
||||
log_info "New instance: $RD_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Scan for a free port block unconditionally, shifting the whole 6-port
|
||||
# block together — not just when adding an explicit additional instance.
|
||||
# A plain first install can just as easily collide with an unrelated
|
||||
# service already bound to one of these default ports — see CLAUDE.md's
|
||||
# "Port collision avoidance" section.
|
||||
while port_in_use "$((21115 + PORT_OFFSET))" \
|
||||
|| port_in_use "$((21116 + PORT_OFFSET))" \
|
||||
|| port_in_use "$((21116 + PORT_OFFSET))" udp \
|
||||
|| port_in_use "$((21117 + PORT_OFFSET))" \
|
||||
|| port_in_use "$((21118 + PORT_OFFSET))" \
|
||||
|| port_in_use "$((21119 + PORT_OFFSET))"; do
|
||||
PORT_OFFSET=$((PORT_OFFSET + 10))
|
||||
done
|
||||
|
||||
mkdir -p "$RD_DIR/rustdesk_data"
|
||||
ensure_docker_dir_ownership "$RD_DIR"
|
||||
cd "$RD_DIR" || return 1
|
||||
|
||||
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 " RustDesk needs to know its own public hostname or IP."
|
||||
@@ -134,23 +208,23 @@ install_rustdesk() {
|
||||
prompt_yn "Require encrypted connections only? (recommended) (y/n):" "y" _enc
|
||||
[ "$_enc" = "n" ] || [ "$_enc" = "N" ] && ENCRYPTED_ONLY="0"
|
||||
|
||||
cat > docker-compose.yml << 'RD_COMPOSE'
|
||||
name: rustdesk
|
||||
cat > docker-compose.yml << RD_COMPOSE
|
||||
name: $CONTAINER
|
||||
|
||||
services:
|
||||
rustdesk:
|
||||
image: rustdesk/rustdesk-server-s6:latest
|
||||
container_name: rustdesk
|
||||
hostname: rustdesk
|
||||
container_name: $CONTAINER
|
||||
hostname: $CONTAINER
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
ports:
|
||||
- "21115:21115"
|
||||
- "21116:21116"
|
||||
- "21116:21116/udp"
|
||||
- "21117:21117"
|
||||
- "21118:21118"
|
||||
- "21119:21119"
|
||||
- "${P_NAT}:21115"
|
||||
- "${P_ID}:21116"
|
||||
- "${P_ID}:21116/udp"
|
||||
- "${P_RELAY}:21117"
|
||||
- "${P_WS}:21118"
|
||||
- "${P_WSS}:21119"
|
||||
volumes:
|
||||
- ./rustdesk_data:/data
|
||||
RD_COMPOSE
|
||||
@@ -161,8 +235,8 @@ TZ=$TZ_VAL
|
||||
|
||||
# ── RustDesk server ───────────────────────────────────────────────────────────
|
||||
# RELAY: public FQDN or IP that clients use to reach the relay daemon (HBBR).
|
||||
# Include the port if it's non-standard: hostname:21117
|
||||
RELAY=$RELAY_HOST:21117
|
||||
# Include the port if it's non-standard: hostname:$P_RELAY
|
||||
RELAY=$RELAY_HOST:$P_RELAY
|
||||
|
||||
# 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
|
||||
@@ -177,13 +251,16 @@ RD_ENV
|
||||
|
||||
chmod 600 .env
|
||||
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
|
||||
# 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
|
||||
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
|
||||
|
||||
@@ -193,8 +270,8 @@ cat $RD_DIR/rustdesk_data/id_ed25519.pub
|
||||
|
||||
Paste this key into each client:
|
||||
**Settings → Network → ID/Relay Server**
|
||||
- ID Server: $RELAY_HOST
|
||||
- Relay Server: $RELAY_HOST
|
||||
- ID Server: $RELAY_HOST:$P_ID
|
||||
- Relay Server: $RELAY_HOST:$P_RELAY
|
||||
- Key: <paste id_ed25519.pub contents>
|
||||
|
||||
## Firewall / router rules required
|
||||
@@ -202,11 +279,11 @@ Paste this key into each client:
|
||||
Open these ports to this server's IP:
|
||||
| Port | Protocol | Purpose |
|
||||
|------|----------|---------|
|
||||
| 21115 | TCP | NAT type test |
|
||||
| 21116 | TCP+UDP | ID register / hole-punching |
|
||||
| 21117 | TCP | Relay traffic |
|
||||
| 21118 | TCP | WebSocket |
|
||||
| 21119 | TCP | WebSocket HTTPS |
|
||||
| $P_NAT | TCP | NAT type test |
|
||||
| $P_ID | TCP+UDP | ID register / hole-punching |
|
||||
| $P_RELAY | TCP | Relay traffic |
|
||||
| $P_WS | TCP | WebSocket |
|
||||
| $P_WSS | TCP | WebSocket HTTPS |
|
||||
|
||||
## Cross-VLAN setup
|
||||
Use the server's FQDN (not LAN IP) in RELAY so clients on any VLAN
|
||||
@@ -224,10 +301,10 @@ docker compose pull && docker compose up -d # update
|
||||
MD
|
||||
|
||||
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
|
||||
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"
|
||||
echo ""
|
||||
echo " After startup, get the public key:"
|
||||
@@ -237,7 +314,7 @@ MD
|
||||
|
||||
echo ""
|
||||
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 ""
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||
@@ -186,13 +201,20 @@ install_stirling-pdf() {
|
||||
log_info "Installing Stirling PDF..."
|
||||
|
||||
local PDF_DIR="$DOCKER_DIR/stirling-pdf"
|
||||
local WEB_PORT="8070"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would create $PDF_DIR"
|
||||
echo "[DRY-RUN] Would write docker-compose.yml and .env"
|
||||
echo "[DRY-RUN] Would auto-scan for a free host port"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Scan for a free host port — a plain install shouldn't silently claim a
|
||||
# port another already-running service holds. See CLAUDE.md's "Port
|
||||
# collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
mkdir -p "$PDF_DIR"
|
||||
ensure_docker_dir_ownership "$PDF_DIR"
|
||||
cd "$PDF_DIR" || return 1
|
||||
@@ -231,7 +253,7 @@ services:
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
ports:
|
||||
- "8070:8080"
|
||||
- "${WEB_PORT}:8080"
|
||||
volumes:
|
||||
- ./training-data:/usr/share/tessdata
|
||||
- ./extraConfigs:/configs
|
||||
@@ -275,7 +297,7 @@ PDF_ENV
|
||||
Feature-rich PDF toolkit: merge, split, compress, rotate, OCR, convert, and more.
|
||||
|
||||
## Access
|
||||
- URL: http://localhost:8070
|
||||
- URL: http://localhost:${WEB_PORT}
|
||||
- No login required by default (security disabled)
|
||||
|
||||
## Enabling built-in auth
|
||||
@@ -314,7 +336,7 @@ MD
|
||||
|| log_warning "Failed to start — check: docker compose logs"
|
||||
fi
|
||||
|
||||
echo " Access at: http://localhost:8070"
|
||||
echo " Access at: http://localhost:${WEB_PORT}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
|
||||
+44
-9
@@ -41,6 +41,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
@@ -157,13 +172,28 @@ install_syncthing() {
|
||||
log_info "Installing Syncthing..."
|
||||
|
||||
local DIR="$DOCKER_DIR/syncthing"
|
||||
local WEB_PORT="8384" SYNC_PORT="22000" DISCOVERY_PORT="21027"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would create $DIR with docker-compose.yml and .env"
|
||||
echo "[DRY-RUN] Would expose web UI on 8384, sync protocol on 22000 (tcp+udp), discovery on 21027/udp"
|
||||
echo "[DRY-RUN] All 3 auto-scanned/shifted together if occupied"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Scan for free host ports, moving all 3 together — a plain install
|
||||
# shouldn't silently claim a port another already-running service holds.
|
||||
# Note: DISCOVERY_PORT (LAN broadcast) only works for zero-config local
|
||||
# discovery at its standard number — shifting it away from 21027 means
|
||||
# peers must be added manually by device ID instead of relying on
|
||||
# auto-discovery. See CLAUDE.md's "Port collision avoidance" section.
|
||||
while port_in_use "$WEB_PORT" || port_in_use "$SYNC_PORT" \
|
||||
|| port_in_use "$SYNC_PORT" udp || port_in_use "$DISCOVERY_PORT" udp; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
SYNC_PORT=$((SYNC_PORT + 1))
|
||||
DISCOVERY_PORT=$((DISCOVERY_PORT + 1))
|
||||
done
|
||||
|
||||
mkdir -p "$DIR"
|
||||
ensure_docker_dir_ownership "$DIR"
|
||||
cd "$DIR" || return 1
|
||||
@@ -209,10 +239,10 @@ services:
|
||||
- PGID=\${PGID:-$PGID}
|
||||
- TZ=\${SITE_TZ:-UTC}
|
||||
ports:
|
||||
- "8384:8384"
|
||||
- "22000:22000/tcp"
|
||||
- "22000:22000/udp"
|
||||
- "21027:21027/udp"
|
||||
- "${WEB_PORT}:8384"
|
||||
- "${SYNC_PORT}:22000/tcp"
|
||||
- "${SYNC_PORT}:22000/udp"
|
||||
- "${DISCOVERY_PORT}:21027/udp"
|
||||
volumes:
|
||||
- ./config:/var/syncthing/config
|
||||
- ./data:/var/syncthing
|
||||
@@ -237,15 +267,20 @@ ENV
|
||||
Continuous, decentralised file synchronisation between devices.
|
||||
|
||||
## Access
|
||||
- Web UI: http://localhost:8384
|
||||
- Web UI: http://localhost:${WEB_PORT}
|
||||
- First run: go to **Settings → GUI** and set a username and password.
|
||||
$( [ "$DISCOVERY_PORT" != "21027" ] && echo "
|
||||
**Note:** the default ports were already in use, so this instance's ports
|
||||
were shifted — see below. Local LAN auto-discovery only works at the
|
||||
standard 21027/udp; with a shifted discovery port, add other devices
|
||||
manually by device ID instead of relying on auto-discovery." )
|
||||
|
||||
## Firewall ports (for LAN sync)
|
||||
Open these on the host firewall so other Syncthing devices can reach this node:
|
||||
\`\`\`
|
||||
sudo ufw allow 22000/tcp comment "Syncthing sync protocol"
|
||||
sudo ufw allow 22000/udp comment "Syncthing sync protocol (QUIC)"
|
||||
sudo ufw allow 21027/udp comment "Syncthing local discovery"
|
||||
sudo ufw allow ${SYNC_PORT}/tcp comment "Syncthing sync protocol"
|
||||
sudo ufw allow ${SYNC_PORT}/udp comment "Syncthing sync protocol (QUIC)"
|
||||
sudo ufw allow ${DISCOVERY_PORT}/udp comment "Syncthing local discovery"
|
||||
\`\`\`
|
||||
|
||||
## Manage
|
||||
@@ -266,7 +301,7 @@ MD
|
||||
|| log_warning "Start failed — check: docker compose logs"
|
||||
fi
|
||||
|
||||
echo " Access at: http://localhost:8384"
|
||||
echo " Access at: http://localhost:${WEB_PORT}"
|
||||
echo " First run: set a username and password in Settings → GUI"
|
||||
echo ""
|
||||
}
|
||||
|
||||
+24
-5
@@ -47,6 +47,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||
prompt_yn() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
@@ -252,10 +267,6 @@ install_traccar() {
|
||||
AUTOHEAL_CONTAINER="traccar-$_suffix-autoheal"
|
||||
AUTOHEAL_LABEL="autoheal-traccar-$_suffix"
|
||||
|
||||
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
done
|
||||
|
||||
# Count existing traccar/traccar-* dirs (this new one isn't
|
||||
# created yet, so the first extra instance counts exactly 1
|
||||
# existing dir -> offset 1 -> 6000-6150).
|
||||
@@ -265,10 +276,18 @@ install_traccar() {
|
||||
PROTO_MIN=$((5000 + _offset))
|
||||
PROTO_MAX=$((5150 + _offset))
|
||||
|
||||
log_info "New instance: $TRACCAR_DIR (web port $WEB_PORT, device protocols $PROTO_MIN-$PROTO_MAX)"
|
||||
log_info "New instance: $TRACCAR_DIR (device protocols $PROTO_MIN-$PROTO_MAX)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Scan for a free web port unconditionally — not just when adding an
|
||||
# explicit additional instance. A plain first install can just as easily
|
||||
# collide with an unrelated service that already claimed this default
|
||||
# port — see CLAUDE.md's "Port collision avoidance" section. The
|
||||
# PROTO_MIN/PROTO_MAX device-protocol range above is a different,
|
||||
# directory-count-based mechanism (not an ss scan) and is unaffected.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
mkdir -p "$TRACCAR_DIR"
|
||||
# Non-recursive on purpose — a rerun already has a `db/` full of Postgres's
|
||||
# own data files, owned by whatever uid the postgres container runs as
|
||||
|
||||
+108
-27
@@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
generate_password() {
|
||||
local _len="${1:-32}"
|
||||
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
|
||||
@@ -98,16 +113,75 @@ register_service unifi utilities "Ubiquiti network controller (UniFi)" 8443
|
||||
install_unifi() {
|
||||
require_docker || return 1
|
||||
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 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
|
||||
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] 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"
|
||||
return 0
|
||||
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"
|
||||
log_info "New instance: $UNIFI_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Scan for free ports unconditionally, moving all 4 together — not just
|
||||
# when adding an explicit additional instance. A plain first install can
|
||||
# just as easily collide with an unrelated service that already claimed
|
||||
# one of these default ports — see CLAUDE.md's "Port collision
|
||||
# avoidance" section.
|
||||
while port_in_use "$WEB_PORT" || port_in_use "$INFORM_PORT" \
|
||||
|| port_in_use "$STUN_PORT" udp || port_in_use "$DISCOVERY_PORT" udp; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
INFORM_PORT=$((INFORM_PORT + 1))
|
||||
STUN_PORT=$((STUN_PORT + 1))
|
||||
DISCOVERY_PORT=$((DISCOVERY_PORT + 1))
|
||||
done
|
||||
|
||||
mkdir -p "$UNIFI_DIR"
|
||||
ensure_docker_dir_ownership "$UNIFI_DIR"
|
||||
cd "$UNIFI_DIR" || return 1
|
||||
@@ -143,13 +217,13 @@ networks:
|
||||
|
||||
# Unquoted heredoc; ${...} used for caddy_net vars; all Docker Compose vars escaped with \$
|
||||
cat > docker-compose.yml << UNIFI_COMPOSE
|
||||
name: unifi
|
||||
name: $PROJECT
|
||||
|
||||
services:
|
||||
unifi-db:
|
||||
image: mongo:4
|
||||
container_name: unifi-db
|
||||
hostname: unifi-db
|
||||
container_name: $DB_CONTAINER
|
||||
hostname: $DB_CONTAINER
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
volumes:
|
||||
@@ -162,8 +236,8 @@ services:
|
||||
|
||||
unifi-app:
|
||||
image: lscr.io/linuxserver/unifi-network-application:latest
|
||||
container_name: unifi-app
|
||||
hostname: unifi-app
|
||||
container_name: $APP_CONTAINER
|
||||
hostname: $APP_CONTAINER
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
depends_on:
|
||||
@@ -171,10 +245,10 @@ services:
|
||||
volumes:
|
||||
- ./unifi_data:/config
|
||||
ports:
|
||||
- "8443:8443"
|
||||
- "8080:8080"
|
||||
- "3478:3478/udp"
|
||||
- "10001:10001/udp"
|
||||
- "${WEB_PORT}:8443"
|
||||
- "${INFORM_PORT}:8080"
|
||||
- "${STUN_PORT}:3478/udp"
|
||||
- "${DISCOVERY_PORT}:10001/udp"
|
||||
# Optional — uncomment as needed:
|
||||
# - "1900:1900/udp" # L2 discovery (may conflict with UPnP)
|
||||
# - "8843:8843" # guest portal HTTPS
|
||||
@@ -216,7 +290,7 @@ UNIFI_ENV
|
||||
mkdir -p mongo_db_data unifi_data
|
||||
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) ──
|
||||
local _caddy_mode="none"
|
||||
@@ -232,15 +306,18 @@ UNIFI_ENV
|
||||
fi
|
||||
echo ""
|
||||
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
|
||||
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
|
||||
if [ -n "$UNIFI_DOMAIN" ]; then
|
||||
# UniFi uses HTTPS internally — upstream must use https:// + skip verify
|
||||
local _upstream="https://unifi-app:8443"
|
||||
[ "$_caddy_mode" = "remote" ] && _upstream="https://${CADDY_REMOTE_HOST}:8443"
|
||||
# UniFi uses HTTPS internally — upstream must use https:// + skip verify.
|
||||
# Local mode reaches the container over Docker networking (internal port
|
||||
# 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
|
||||
_site_block="$(cat << CBLOCK
|
||||
@@ -277,7 +354,7 @@ CBLOCK
|
||||
|| log_warning "Caddy reload failed — check: docker logs caddy"
|
||||
else
|
||||
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"
|
||||
printf '%s\n' "$_site_block" > "$_snippet_file"
|
||||
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
|
||||
@@ -290,25 +367,29 @@ CBLOCK
|
||||
fi
|
||||
|
||||
write_readme "$UNIFI_DIR" << MD
|
||||
# UniFi Network Application
|
||||
# UniFi Network Application${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||
|
||||
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
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
|
||||
## Ports
|
||||
| Port | Protocol | Purpose |
|
||||
|------|----------|---------|
|
||||
| 8443 | TCP | HTTPS web UI |
|
||||
| 8080 | TCP | Device inform / HTTP redirect |
|
||||
| 3478 | UDP | STUN |
|
||||
| 10001 | UDP | AP discovery |
|
||||
| ${WEB_PORT} | TCP | HTTPS web UI |
|
||||
| ${INFORM_PORT} | TCP | Device inform / HTTP redirect |
|
||||
| ${STUN_PORT} | UDP | STUN |
|
||||
| ${DISCOVERY_PORT} | UDP | AP discovery |
|
||||
|
||||
## Manage
|
||||
\`\`\`bash
|
||||
@@ -327,15 +408,15 @@ docker compose pull && docker compose up -d # update (wait for DB first)
|
||||
MD
|
||||
|
||||
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
|
||||
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"
|
||||
fi
|
||||
|
||||
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 ""
|
||||
}
|
||||
|
||||
+25
-3
@@ -44,6 +44,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||
prompt_yn() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
@@ -186,12 +201,19 @@ install_uptimekuma() {
|
||||
require_docker || return 1
|
||||
log_info "Installing Uptime Kuma..."
|
||||
local UPTIME_DIR="$DOCKER_DIR/uptime-kuma"
|
||||
local WEB_PORT="3001"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] Would create $UPTIME_DIR"
|
||||
echo "[DRY-RUN] Would auto-scan for a free host port"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Scan for a free host port — a plain install shouldn't silently claim a
|
||||
# port another already-running service holds. See CLAUDE.md's "Port
|
||||
# collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
mkdir -p "$UPTIME_DIR"
|
||||
ensure_docker_dir_ownership "$UPTIME_DIR"
|
||||
cd "$UPTIME_DIR" || return 1
|
||||
@@ -232,7 +254,7 @@ services:
|
||||
- ./data:/app/data
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
ports:
|
||||
- "3001:3001"
|
||||
- "${WEB_PORT}:3001"
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
UPTIME_COMPOSE
|
||||
|
||||
@@ -249,7 +271,7 @@ Self-hosted uptime/status monitoring dashboard. Monitor websites, servers, and
|
||||
Docker containers.
|
||||
|
||||
## Access
|
||||
- URL: http://localhost:3001
|
||||
- URL: http://localhost:${WEB_PORT}
|
||||
- Create your admin account on first visit.
|
||||
|
||||
## Data
|
||||
@@ -278,7 +300,7 @@ MD
|
||||
docker compose up -d 2>/dev/null && log_success "Uptime Kuma started" || log_warning "Failed to start"
|
||||
fi
|
||||
|
||||
echo " Access at: http://localhost:3001"
|
||||
echo " Access at: http://localhost:${WEB_PORT}"
|
||||
echo ""
|
||||
}
|
||||
|
||||
|
||||
+76
-11
@@ -48,6 +48,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
generate_password() {
|
||||
local _len="${1:-32}"
|
||||
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
|
||||
@@ -202,16 +217,63 @@ register_service vaultwarden utilities "Bitwarden-compatible password manager (V
|
||||
install_vaultwarden() {
|
||||
require_docker || return 1
|
||||
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 INSTANCE_SUFFIX="" CONTAINER="vaultwarden"
|
||||
local WEB_PORT="8888"
|
||||
|
||||
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 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)"
|
||||
return 0
|
||||
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"
|
||||
log_info "New instance: $VW_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Scan for a free port unconditionally — not just when adding an explicit
|
||||
# additional instance. A plain first install can just as easily collide
|
||||
# with an unrelated service that already claimed this default port — see
|
||||
# CLAUDE.md's "Port collision avoidance" section.
|
||||
find_free_port WEB_PORT "$WEB_PORT"
|
||||
|
||||
mkdir -p "$VW_DIR/vaultwarden_data"
|
||||
ensure_docker_dir_ownership "$VW_DIR"
|
||||
cd "$VW_DIR" || return 1
|
||||
@@ -225,7 +287,7 @@ install_vaultwarden() {
|
||||
echo " can connect and password-reset emails link correctly."
|
||||
echo ""
|
||||
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
|
||||
[ -z "$VW_DOMAIN" ] && VW_DOMAIN="$DEFAULT_DOMAIN"
|
||||
|
||||
@@ -266,19 +328,19 @@ networks:
|
||||
fi
|
||||
|
||||
cat > docker-compose.yml << VW_COMPOSE
|
||||
name: vaultwarden
|
||||
name: $CONTAINER
|
||||
|
||||
services:
|
||||
vaultwarden:
|
||||
image: vaultwarden/server:latest
|
||||
container_name: vaultwarden
|
||||
hostname: vaultwarden
|
||||
container_name: $CONTAINER
|
||||
hostname: $CONTAINER
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./vaultwarden_data:/data
|
||||
ports:
|
||||
- "8888:80"
|
||||
- "${WEB_PORT}:80"
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
VW_COMPOSE
|
||||
|
||||
@@ -311,15 +373,18 @@ VW_ENV
|
||||
|
||||
chmod 600 .env
|
||||
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
|
||||
# Vaultwarden — Bitwarden-compatible password manager
|
||||
# Vaultwarden${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX} — Bitwarden-compatible password manager
|
||||
|
||||
Lightweight, self-hosted Bitwarden server. Works with all official
|
||||
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
|
||||
1. Point your Bitwarden client to: $VW_DOMAIN
|
||||
@@ -353,10 +418,10 @@ docker compose pull && docker compose up -d # update
|
||||
MD
|
||||
|
||||
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
|
||||
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"
|
||||
fi
|
||||
|
||||
|
||||
+37
-10
@@ -49,6 +49,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||
prompt_text() {
|
||||
local _q="$1" _def="$2" _var="$3" _r
|
||||
@@ -198,6 +213,7 @@ install_wg-easy() {
|
||||
require_docker || return 1
|
||||
|
||||
local WGEASY_DIR="$DOCKER_DIR/wg-easy"
|
||||
local WEB_PORT="51821" VPN_PORT="51820"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo "[DRY-RUN] wg-easy would:"
|
||||
@@ -205,14 +221,24 @@ install_wg-easy() {
|
||||
echo " - Auto-detect public IP for WG_HOST"
|
||||
echo " - Generate a random web UI password"
|
||||
echo " - Pin WG_DEFAULT_ADDRESS=10.8.0.x (subnet 10.8.0.0/24)"
|
||||
echo " - Expose port 51821 (web UI) + 51820/udp (VPN)"
|
||||
echo " - Require router port-forward: UDP 51820 → this server"
|
||||
echo " - Expose port 51821 (web UI) + 51820/udp (VPN), both auto-scanned if occupied"
|
||||
echo " - Require router port-forward: UDP <VPN port> → this server"
|
||||
echo " - Offer a Caddy reverse proxy and to start the container"
|
||||
echo " - Offer to also allow SSH from the VPN subnet (additive, doesn't remove public SSH)"
|
||||
echo " - Write sync-ssh-aliases.sh — generates ~/.ssh/config aliases for connected peers"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Scan for free host ports, moving both together — a plain install
|
||||
# shouldn't silently claim a port another already-running service holds.
|
||||
# Whatever VPN_PORT ends up as is what needs forwarding on the router
|
||||
# (the messaging below reflects the final value, not the 51820 default).
|
||||
# See CLAUDE.md's "Port collision avoidance" section.
|
||||
while port_in_use "$WEB_PORT" || port_in_use "$VPN_PORT" udp; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
VPN_PORT=$((VPN_PORT + 1))
|
||||
done
|
||||
|
||||
mkdir -p "$WGEASY_DIR"
|
||||
ensure_docker_dir_ownership "$WGEASY_DIR"
|
||||
cd "$WGEASY_DIR" || return 1
|
||||
@@ -287,11 +313,12 @@ services:
|
||||
- WG_DEFAULT_DNS=1.1.1.1
|
||||
- WG_DEFAULT_ADDRESS=${WG_DEFAULT_ADDRESS}
|
||||
- WG_ALLOWED_IPS=0.0.0.0/0, ::/0
|
||||
- WG_PORT=${VPN_PORT}
|
||||
volumes:
|
||||
- ./config:/etc/wireguard
|
||||
ports:
|
||||
- "51820:51820/udp"
|
||||
- "51821:51821/tcp"
|
||||
- "${VPN_PORT}:${VPN_PORT}/udp"
|
||||
- "${WEB_PORT}:51821/tcp"
|
||||
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
|
||||
WGEASY_COMPOSE
|
||||
|
||||
@@ -416,8 +443,8 @@ SYNCEOF
|
||||
WireGuard VPN with a web UI for managing clients, generating QR codes,
|
||||
and monitoring connections.
|
||||
|
||||
- Web UI: http://localhost:51821
|
||||
- VPN: UDP port 51820 (forward this on your router)
|
||||
- Web UI: http://localhost:${WEB_PORT}
|
||||
- VPN: UDP port ${VPN_PORT} (forward this on your router)
|
||||
- Password: stored in \`.env\` (\`WG_PASSWORD\`)
|
||||
- VPN host: \`$WG_HOST\` (update \`WG_HOST\` in .env if your IP changes)
|
||||
- VPN subnet: \`$WG_SUBNET_CIDR\`
|
||||
@@ -433,10 +460,10 @@ docker compose pull && docker compose up -d # update
|
||||
\`\`\`
|
||||
|
||||
## Router setup
|
||||
Forward **UDP port 51820** to this server's LAN IP for external VPN access.
|
||||
Forward **UDP port ${VPN_PORT}** to this server's LAN IP for external VPN access.
|
||||
|
||||
## Adding clients
|
||||
Open http://localhost:51821, log in with your password, click "+ New Client",
|
||||
Open http://localhost:${WEB_PORT}, log in with your password, click "+ New Client",
|
||||
download or scan the QR code with the WireGuard app.
|
||||
|
||||
## Mesh — peers reach each other automatically
|
||||
@@ -471,11 +498,11 @@ MD
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " Web UI: http://localhost:51821"
|
||||
echo " Web UI: http://localhost:${WEB_PORT}"
|
||||
echo " Password: $WG_PASSWORD (saved in .env)"
|
||||
[[ -n "$WG_PASSWORD_HASH" ]] && echo " Auth: bcrypt hash configured (v14+ compatible)" \
|
||||
|| echo " Auth: WARNING — bcrypt hash generation failed; see README"
|
||||
echo " Router: forward UDP 51820 → this server for external VPN access"
|
||||
echo " Router: forward UDP ${VPN_PORT} → this server for external VPN access"
|
||||
echo ""
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||
}
|
||||
|
||||
port_in_use() {
|
||||
local _port="$1" _proto="${2:-tcp}"
|
||||
local _flag="-tlnH"
|
||||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||||
}
|
||||
|
||||
find_free_port() {
|
||||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||||
while port_in_use "$_port" "$_proto"; do
|
||||
_port=$((_port + 1))
|
||||
done
|
||||
eval "$_varname='$_port'"
|
||||
}
|
||||
|
||||
generate_password() {
|
||||
local _len="${1:-32}"
|
||||
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
|
||||
|
||||
Reference in New Issue
Block a user