Bake cross-service port collision avoidance into every service script
With 70+ services sharing a handful of common default ports (emby and jellyfin both default to 8096, changedetection and frigate both default to 5000, arm and nextcloud both default to 8080...), nothing previously checked whether a service's default port was actually free on the host. Whichever service installed second would silently write a compose file claiming an already-held port, only failing at `docker compose up` time. Adds two shared helpers to lib/common.sh: - port_in_use PORT [PROTO] — true if something's already listening - find_free_port VARNAME START [PROTO] — scans upward, writes back the first free port Every service that publishes a fixed host port now scans before writing docker-compose.yml, on every install (not just when adding an explicit additional instance). On a normal single-install host this is a silent no-op; it only changes behavior when something else already holds the port. - The 19 services already given multi-instance support this session had their port scan moved out of the "add instance" branch to run unconditionally, since the same collision risk exists on a plain first install. - 20 more services with previously-hardcoded ports gained scanning for the first time: archivebox, arm, calibre-web, changedetection, drum-rhythm-game, gatus, n8n, nextcloud, onlyoffice, stirling-pdf, uptimekuma, portainer, iopaint (both GPU/CPU compose branches), koha (paired), syncthing (paired), wg-easy (paired, plus WG_PORT env so generated peer configs keep the right Endpoint), homeassistant (bridge-mode only — host mode can only warn), frigate and frigate-audio (multi-port stacks, moved together). - caddy.sh is the deliberate exception: 80/443 stay fixed and only warn on collision, since silently moving Caddy itself would leave nothing listening where any client actually looks. - authelia.sh needs no change — it has no published host port at all. - Every service's standalone bootstrap fallback (sudo bash services/x.sh with no sibling files) got the same two helpers duplicated into its stub block, matching how every other shared helper is already handled there. Documents the full pattern in CLAUDE.md's new "Port collision avoidance" section, including the quoted-heredoc/backtick-escaping gotcha and the network_mode:host limitation (can only scan ports the app takes as a configurable env var). Verified via bash -n on every changed file, plus functional runs seeding occupied ports for each collision shape used here (single, paired, multi-port stacks) and confirming the scan/shift and generated compose/README output are correct — including the emby/jellyfin, nextcloud/arm, and frigate/changedetection collision scenarios that originally motivated this.
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"),
|
||||
|
||||
@@ -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; }
|
||||
@@ -229,14 +244,17 @@ install_actualbudget() {
|
||||
INSTANCE_SUFFIX="$_suffix"
|
||||
AB_DIR="$DOCKER_DIR/actualbudget-$_suffix"
|
||||
CONTAINER="actualbudget-$_suffix"
|
||||
|
||||
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
done
|
||||
log_info "New instance: $AB_DIR (port $WEB_PORT)"
|
||||
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
|
||||
|
||||
+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
|
||||
|
||||
|
||||
+22
-5
@@ -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; }
|
||||
@@ -226,14 +241,16 @@ install_filebrowser() {
|
||||
INSTANCE_SUFFIX="$_suffix"
|
||||
FB_DIR="$DOCKER_DIR/filebrowser-$_suffix"
|
||||
CONTAINER="filebrowser-$_suffix"
|
||||
|
||||
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
done
|
||||
log_info "New instance: $FB_DIR (port $WEB_PORT)"
|
||||
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
|
||||
|
||||
+22
-5
@@ -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
|
||||
@@ -240,14 +255,16 @@ install_fmd() {
|
||||
INSTANCE_SUFFIX="$_suffix"
|
||||
FMD_DIR="$DOCKER_DIR/fmd-$_suffix"
|
||||
CONTAINER="fmd-$_suffix"
|
||||
|
||||
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
done
|
||||
log_info "New instance: $FMD_DIR (port $WEB_PORT)"
|
||||
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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
+22
-5
@@ -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; }
|
||||
@@ -227,14 +242,16 @@ install_homebox() {
|
||||
INSTANCE_SUFFIX="$_suffix"
|
||||
HB_DIR="$DOCKER_DIR/homebox-$_suffix"
|
||||
CONTAINER="homebox-$_suffix"
|
||||
|
||||
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
done
|
||||
log_info "New instance: $HB_DIR (port $WEB_PORT)"
|
||||
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
|
||||
|
||||
+22
-5
@@ -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; }
|
||||
@@ -250,14 +265,16 @@ install_immich() {
|
||||
C_REDIS="immich_redis_$_suffix"
|
||||
C_DB="immich_postgres_$_suffix"
|
||||
DEFAULT_PHOTOS="$ACTUAL_HOME/photos-$_suffix"
|
||||
|
||||
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
done
|
||||
log_info "New instance: $IMMICH_DIR (port $WEB_PORT)"
|
||||
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"
|
||||
|
||||
+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"
|
||||
|
||||
+23
-5
@@ -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; }
|
||||
@@ -238,17 +253,20 @@ install_jellyfin() {
|
||||
JELLYFIN_DIR="$DOCKER_DIR/jellyfin-$_suffix"
|
||||
CONTAINER="jellyfin-$_suffix"
|
||||
DEFAULT_MEDIA="$ACTUAL_HOME/media-$_suffix"
|
||||
|
||||
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
done
|
||||
log_info "New instance: $JELLYFIN_DIR (port $WEB_PORT)"
|
||||
log_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%/}"
|
||||
|
||||
+22
-5
@@ -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; }
|
||||
@@ -232,14 +247,16 @@ install_joplin() {
|
||||
JOPLIN_DIR="$DOCKER_DIR/joplin-$_suffix"
|
||||
CONTAINER="joplin-$_suffix"
|
||||
DB_CONTAINER="joplin-db-$_suffix"
|
||||
|
||||
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
done
|
||||
log_info "New instance: $JOPLIN_DIR (port $WEB_PORT)"
|
||||
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
|
||||
|
||||
+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 ""
|
||||
}
|
||||
|
||||
|
||||
+42
-10
@@ -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
|
||||
@@ -263,18 +278,29 @@ install_lyrion() {
|
||||
log_warning "don't collide with the first instance's fixed ports. This instance"
|
||||
log_warning "loses zero-config Chromecast/Squeezebox discovery — enter its address"
|
||||
log_warning "manually in the Squeezer app / Squeezebox firmware instead."
|
||||
|
||||
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q . \
|
||||
|| ss -tlnH "sport = :${CLI_PORT}" 2>/dev/null | grep -q . \
|
||||
|| ss -tlnH "sport = :${PLAYER_PORT}" 2>/dev/null | grep -q .; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
CLI_PORT=$((CLI_PORT + 1))
|
||||
PLAYER_PORT=$((PLAYER_PORT + 1))
|
||||
done
|
||||
log_info "New instance: $LYRION_DIR (web $WEB_PORT, CLI $CLI_PORT, player $PLAYER_PORT)"
|
||||
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%/}"
|
||||
@@ -287,9 +313,14 @@ 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:
|
||||
@@ -297,6 +328,7 @@ install_lyrion() {
|
||||
- \"${CLI_PORT}:9090\"
|
||||
- \"${PLAYER_PORT}:3483\"
|
||||
"
|
||||
_HTTP_PORT_INTERNAL="9000"
|
||||
fi
|
||||
|
||||
cat > docker-compose.yml << LYRION_COMPOSE
|
||||
@@ -309,7 +341,7 @@ services:
|
||||
hostname: $CONTAINER
|
||||
restart: unless-stopped
|
||||
${_NETWORK_BLOCK}${_PORTS_BLOCK} environment:
|
||||
- HTTP_PORT=9000
|
||||
- HTTP_PORT=$_HTTP_PORT_INTERNAL
|
||||
- PUID=$UID_VAL
|
||||
- PGID=$GID_VAL
|
||||
- TZ=$TZ_VAL
|
||||
|
||||
+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
|
||||
|
||||
+26
-7
@@ -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
|
||||
@@ -241,16 +256,20 @@ install_meshcentral() {
|
||||
INSTANCE_SUFFIX="$_suffix"
|
||||
MC_DIR="$DOCKER_DIR/meshcentral-$_suffix"
|
||||
CONTAINER="meshcentral-$_suffix"
|
||||
|
||||
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q . \
|
||||
|| ss -tlnH "sport = :${AGENT_PORT}" 2>/dev/null | grep -q .; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
AGENT_PORT=$((AGENT_PORT + 1))
|
||||
done
|
||||
log_info "New instance: $MC_DIR (web port $WEB_PORT, agent port $AGENT_PORT)"
|
||||
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}"
|
||||
|
||||
+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"
|
||||
|
||||
+22
-5
@@ -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
|
||||
@@ -235,14 +250,16 @@ install_ntfy() {
|
||||
INSTANCE_SUFFIX="$_suffix"
|
||||
NTFY_DIR="$DOCKER_DIR/ntfy-$_suffix"
|
||||
CONTAINER="ntfy-$_suffix"
|
||||
|
||||
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
done
|
||||
log_info "New instance: $NTFY_DIR (port $WEB_PORT)"
|
||||
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
|
||||
|
||||
+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 ""
|
||||
}
|
||||
|
||||
+30
-11
@@ -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
|
||||
@@ -149,20 +164,24 @@ install_rustdesk() {
|
||||
INSTANCE_SUFFIX="$_suffix"
|
||||
RD_DIR="$DOCKER_DIR/rustdesk-$_suffix"
|
||||
CONTAINER="rustdesk-$_suffix"
|
||||
|
||||
PORT_OFFSET=10
|
||||
while ss -tlnH "sport = :$((21115 + PORT_OFFSET))" 2>/dev/null | grep -q . \
|
||||
|| ss -tlnH "sport = :$((21116 + PORT_OFFSET))" 2>/dev/null | grep -q . \
|
||||
|| ss -ulnH "sport = :$((21116 + PORT_OFFSET))" 2>/dev/null | grep -q . \
|
||||
|| ss -tlnH "sport = :$((21117 + PORT_OFFSET))" 2>/dev/null | grep -q . \
|
||||
|| ss -tlnH "sport = :$((21118 + PORT_OFFSET))" 2>/dev/null | grep -q . \
|
||||
|| ss -tlnH "sport = :$((21119 + PORT_OFFSET))" 2>/dev/null | grep -q .; do
|
||||
PORT_OFFSET=$((PORT_OFFSET + 10))
|
||||
done
|
||||
log_info "New instance: $RD_DIR (ports $((21115 + PORT_OFFSET))-$((21119 + PORT_OFFSET)))"
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
+29
-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"
|
||||
@@ -150,20 +165,23 @@ install_unifi() {
|
||||
PROJECT="unifi-$_suffix"
|
||||
DB_CONTAINER="unifi-db-$_suffix"
|
||||
APP_CONTAINER="unifi-app-$_suffix"
|
||||
|
||||
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q . \
|
||||
|| ss -tlnH "sport = :${INFORM_PORT}" 2>/dev/null | grep -q . \
|
||||
|| ss -ulnH "sport = :${STUN_PORT}" 2>/dev/null | grep -q . \
|
||||
|| ss -ulnH "sport = :${DISCOVERY_PORT}" 2>/dev/null | grep -q .; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
INFORM_PORT=$((INFORM_PORT + 1))
|
||||
STUN_PORT=$((STUN_PORT + 1))
|
||||
DISCOVERY_PORT=$((DISCOVERY_PORT + 1))
|
||||
done
|
||||
log_info "New instance: $UNIFI_DIR (web $WEB_PORT, inform $INFORM_PORT, STUN $STUN_PORT, discovery $DISCOVERY_PORT)"
|
||||
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
|
||||
|
||||
+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 ""
|
||||
}
|
||||
|
||||
|
||||
+22
-5
@@ -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"
|
||||
@@ -249,14 +264,16 @@ install_vaultwarden() {
|
||||
INSTANCE_SUFFIX="$_suffix"
|
||||
VW_DIR="$DOCKER_DIR/vaultwarden-$_suffix"
|
||||
CONTAINER="vaultwarden-$_suffix"
|
||||
|
||||
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
|
||||
WEB_PORT=$((WEB_PORT + 1))
|
||||
done
|
||||
log_info "New instance: $VW_DIR (port $WEB_PORT)"
|
||||
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
|
||||
|
||||
+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