Make multi-instance work without a domain

An instance's isolation never needed a domain — its own slug, dir, secrets,
IP and randomly-allocated host port already make two copies independent. But
the routing layer assumed one, so a LAN-only box got a broken instance rather
than a port-served one. Four fixes:

- instanceCreate now rewrites the parent-service column of the cloned config's
  PORT_ rows to match the service names it stamps into the compose. That value
  is stored as network_resources.parent_service and joined against the
  compose-derived service names, so an instance left carrying the TYPE's
  service name matched nothing: it rendered in the WebUI with no port, no URL
  and no login row despite being up and reachable.

- `instance create --local` (plus a LAN-only toggle in the modal) forces every
  port to access=private, traefik=false, for a second copy that should stay
  off the domain even when one is configured.

- initializeAppVariables forces the traefik column false when no CFG_DOMAIN_n
  is set. Previously a traefik=true port with an empty domain stamped
  Host(`app.`) — a trailing-dot host matching nothing — and dragged APP_URL to
  https://app. with it, breaking every app that builds its links from APP_URL.
  host_setup is blanked for the same reason. The published host port is
  untouched; access type, not the traefik flag, gates allocation.

- APP_URL's direct host-port branch now prefers a new $local_ip_v4 (the source
  IP for the default route) over $public_ip_v4, which is the WAN address from
  an external resolver. LibrePortal never forwards ports, so the WAN address
  was unreachable for exactly the LAN/VPN clients that branch serves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
librelad 2026-08-19 02:53:43 +01:00
parent 63b3af4cfc
commit e25c69e2a1
12 changed files with 248 additions and 28 deletions

View File

@ -686,6 +686,41 @@
color: var(--text-secondary);
min-height: 14px;
}
/* LAN-only toggle. Row-oriented, unlike .lp-instance-field, so the label sits
beside the box; the checkbox keeps its intrinsic size rather than inheriting
the full-width input rule above. */
.lp-instance-check {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 14px;
font-size: 13px;
color: var(--text-primary);
cursor: pointer;
}
.lp-instance-check input {
width: 15px;
height: 15px;
flex: 0 0 auto;
accent-color: var(--accent);
cursor: pointer;
}
.lp-instance-check input:disabled {
cursor: default;
}
.lp-instance-check:has(input:disabled) {
cursor: default;
opacity: 0.75;
}
/* The "no domain configured" note sits between the toggle and the preview line.
.lp-instance-hint carries no bottom margin (it normally hangs under a field
that supplies its own), so without this it butts straight into "Will be served
at" and the two read as one run-on paragraph. */
.lp-instance-check + .lp-instance-hint {
display: block;
margin: -6px 0 14px;
line-height: 1.45;
}
.lp-instance-row {
display: flex;
gap: 12px;

View File

@ -61,9 +61,14 @@ class InstanceManager {
const title = this._typeTitle(typeSlug);
this.domains = await this._loadDomains();
const domainOptions = this.domains.length
// With no CFG_DOMAIN_n set, a subdomain has nothing to attach to — the backend
// would stamp Host(`sub.`) and an unreachable APP_URL. So the modal drops to
// LAN-only and locks the choice, matching what initializeAppVariables enforces
// server-side. Presenting a domain picker here would have been a lie.
const hasDomains = this.domains.length > 0;
const domainOptions = hasDomains
? this.domains.map(d => `<option value="${d.number}">${this._esc(d.domain)}</option>`).join('')
: '<option value="1">Domain 1 (set one in Admin → Config)</option>';
: '<option value="1">No domain configured</option>';
const overlay = document.createElement('div');
overlay.id = 'lp-instance-modal';
@ -74,7 +79,7 @@ class InstanceManager {
<h3>New ${this._esc(title)} instance</h3>
<button type="button" class="lp-instance-x" aria-label="Close">&times;</button>
</div>
<p class="lp-instance-sub">A fully isolated second copy its own data, database, subdomain, backups and update cadence.</p>
<p class="lp-instance-sub">A fully isolated second copy its own data, database, ports, backups and update cadence.</p>
<label class="lp-instance-field">
<span>Instance name</span>
@ -82,7 +87,13 @@ class InstanceManager {
<small id="lp-instance-slug" class="lp-instance-hint"></small>
</label>
<div class="lp-instance-row">
<label class="lp-instance-check">
<input type="checkbox" id="lp-instance-local" ${hasDomains ? '' : 'checked disabled'} />
<span>LAN only no domain, served on its own port</span>
</label>
${hasDomains ? '' : '<small class="lp-instance-hint">No domain is configured, so instances are LAN-only. Add one in Admin → Config to route them on a subdomain.</small>'}
<div class="lp-instance-row" id="lp-instance-domain-row">
<label class="lp-instance-field">
<span>Domain</span>
<select id="lp-instance-domain" class="form-control">${domainOptions}</select>
@ -110,6 +121,8 @@ class InstanceManager {
const subEl = overlay.querySelector('#lp-instance-subdomain');
const domEl = overlay.querySelector('#lp-instance-domain');
const hostEl = overlay.querySelector('#lp-instance-host');
const localEl = overlay.querySelector('#lp-instance-local');
const domainRow = overlay.querySelector('#lp-instance-domain-row');
const createBtn = overlay.querySelector('.lp-instance-create');
let subEdited = false;
@ -120,14 +133,25 @@ class InstanceManager {
const slug = id ? `${typeSlug}_${id}` : '';
slugEl.textContent = slug ? `Created as ${slug}` : 'Letters and numbers only';
if (!subEdited) subEl.value = slug ? slug.replace(/_/g, '-') : '';
const dom = (this.domains.find(d => String(d.number) === domEl.value) || {}).domain || '<your-domain>';
const sub = (subEl.value || '').trim();
hostEl.textContent = sub ? `${sub}.${dom}` : ``;
const isLocal = localEl.checked;
domainRow.style.display = isLocal ? 'none' : '';
if (isLocal) {
// The port is allocated at install time, so it genuinely isn't known yet —
// say so rather than invent one. Kept short: the preview line is a single
// <code> run and a longer string wraps mid-token inside the modal.
hostEl.textContent = 'http://<this-server>:<auto-port>';
} else {
const dom = (this.domains.find(d => String(d.number) === domEl.value) || {}).domain || '<your-domain>';
const sub = (subEl.value || '').trim();
hostEl.textContent = sub ? `${sub}.${dom}` : '…';
}
createBtn.disabled = !id;
};
nameEl.addEventListener('input', refresh);
domEl.addEventListener('change', refresh);
subEl.addEventListener('input', refresh);
localEl.addEventListener('change', refresh);
refresh();
setTimeout(() => nameEl.focus(), 30);
@ -137,10 +161,12 @@ class InstanceManager {
overlay.querySelector('.lp-instance-cancel').addEventListener('click', () => this.close());
overlay.addEventListener('click', (e) => { if (e.target === overlay) this.close(); });
createBtn.addEventListener('click', () => this._submit(typeSlug, nameEl.value, domEl.value, subEl.value));
createBtn.addEventListener('click', () => this._submit(
typeSlug, nameEl.value, domEl.value, subEl.value, localEl.checked
));
}
async _submit(typeSlug, name, domainIndex, subdomain) {
async _submit(typeSlug, name, domainIndex, subdomain, localOnly) {
const id = this._idPart(name);
if (!id) return;
const slug = `${typeSlug}_${id}`;
@ -165,8 +191,11 @@ class InstanceManager {
await window.tasksManager.router.routeAction('instance_create', {
type: typeSlug,
name: name,
domainIndex: domainIndex,
subdomain: subdomain
// Domain/subdomain are meaningless for a LAN-only instance and would
// otherwise be passed through to a config the --local pass then overwrites.
domainIndex: localOnly ? '' : domainIndex,
subdomain: localOnly ? '' : subdomain,
localOnly: !!localOnly
});
this.close();
notify(`Creating instance ${slug} — track progress in Tasks.`, 'success');

View File

@ -34,12 +34,16 @@ class TaskActions {
* like any install (button state, task icon, progress), while the verbatim
* command carries the optional domain#/subdomain through executeTask.
*/
async instanceCreate(type, name, domainIndex = '', subdomain = '') {
async instanceCreate(type, name, domainIndex = '', subdomain = '', localOnly = false) {
try {
this.commands.validateCommand('instance_create', { type, name });
const parts = ['libreportal', 'instance', 'create', type, name];
if (domainIndex) parts.push(domainIndex);
if (subdomain) parts.push(subdomain);
// LAN-only: no Traefik router, served on its published host port. Appended
// last, but the CLI pulls the flag out of the positional list, so it never
// shifts domain#/subdomain.
if (localOnly) parts.push('--local');
// The new instance's slug — mirrors the backend's <type>_<id> scheme so
// monitorTask/highlight track the right app once it appears.
const id = String(name).toLowerCase().replace(/[^a-z0-9]/g, '');

View File

@ -22,7 +22,7 @@ class TaskRouter {
return await this.actions.installApp(params.appName, params.config, params.resetNetwork);
case 'instance_create':
return await this.actions.instanceCreate(params.type, params.name, params.domainIndex, params.subdomain);
return await this.actions.instanceCreate(params.type, params.name, params.domainIndex, params.subdomain, params.localOnly);
case 'instance_remove':
return await this.actions.instanceRemove(params.appName);

View File

@ -9,24 +9,43 @@
cliHandleInstanceCommands()
{
local action="$initial_command2"
local type="$initial_command3"
local name="$initial_command4"
local domain_idx="$initial_command5"
local subdomain="$initial_command6"
# --local/--lan is pulled out of the positional list rather than given a fixed
# slot, so it can be written anywhere after the verb — `instance create
# bookstack home --local` reads naturally and still leaves domain#/subdomain
# in their documented positions for callers that pass them.
local local_only="false"
local -a _pos=()
local _a
for _a in "$initial_command3" "$initial_command4" "$initial_command5" \
"$initial_command6" "$initial_command7"; do
case "$_a" in
"--local"|"--lan") local_only="true" ;;
*) _pos+=("$_a") ;;
esac
done
local type="${_pos[0]}"
local name="${_pos[1]}"
local domain_idx="${_pos[2]}"
local subdomain="${_pos[3]}"
case "$action" in
"create")
if [[ -z "$type" || -z "$name" ]]; then
isNotice "Usage: libreportal instance create <type> <name> [domain_index] [subdomain]"
isNotice "Usage: libreportal instance create <type> <name> [domain_index] [subdomain] [--local]"
cliShowInstanceHelp
return 1
fi
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
instanceCreate "$type" "$name" "$domain_idx" "$subdomain"
instanceCreate "$type" "$name" "$domain_idx" "$subdomain" "$local_only"
else
local _cmd="libreportal instance create $type $name"
[[ -n "$domain_idx" ]] && _cmd+=" $domain_idx"
[[ -n "$subdomain" ]] && _cmd+=" $subdomain"
# Re-invocation must carry the flag or the task-side run would
# silently build a Traefik-routed instance instead.
[[ "$local_only" == "true" ]] && _cmd+=" --local"
cliTaskRun "$_cmd" "install" "${type}_$(instanceIdPart "$name")"
fi
;;

View File

@ -13,12 +13,15 @@ cliShowInstanceHelp()
echo " its own data, DB, subdomain, backups and update cadence. Only apps"
echo " with CFG_<TYPE>_MULTI_INSTANCE=true can be instanced."
echo ""
echo " libreportal instance create [type*] [name*] [domain#] [subdomain]"
echo " libreportal instance create [type*] [name*] [domain#] [subdomain] [--local]"
echo " - Provision + install a new instance."
echo " type = base app slug (e.g. bookstack)"
echo " name - instance name (e.g. blog)"
echo " domain# - which CFG_DOMAIN_n to route on (default 1)"
echo " subdomain- host label (default <type>-<name>)"
echo " --local - LAN only: no Traefik router, served"
echo " on its own port. Implied when no"
echo " CFG_DOMAIN_n is configured."
echo " libreportal instance remove [slug*] - Uninstall + remove an instance (e.g. bookstack_blog)"
echo " libreportal instance list [type] - List instances (all, or just for one app type)"
echo ""

View File

@ -97,7 +97,7 @@ dockerConfigSetupFileWithData()
ipUpdateComposeTags "$app_name" "$full_file_path"
portUpdateComposeTags "$app_name" "$full_file_path"
tagsProcessorTrustedDomains "$full_file_path"
tagsProcessorAppUrl "$full_file_path" "$app_name" "$public" "$host_setup" "$public_ip_v4"
tagsProcessorAppUrl "$full_file_path" "$app_name" "$public" "$host_setup" "$public_ip_v4" "$local_ip_v4"
###############################################
# Mail Server Settings

View File

@ -1,5 +1,9 @@
#!/bin/bash
# Stamps APP_URL_TAG — the canonical URL an app advertises to itself. Apps that
# build every link, redirect and asset path from it (Bookstack, Nextcloud,
# Mastodon) break outright when it doesn't match how the user actually reaches
# them, so the two branches below must each name a host that really resolves.
tagsProcessorAppUrl()
{
local full_file_path="$1"
@ -7,6 +11,7 @@ tagsProcessorAppUrl()
local is_public="$3"
local host_setup="$4"
local public_ip_v4="$5"
local local_ip_v4="$6"
local traefik_installed=""
if [[ -f "$docker_dir/$db_file" ]] && command -v sqlite3 >/dev/null 2>&1; then
@ -21,7 +26,12 @@ tagsProcessorAppUrl()
if [[ -f "$docker_dir/$db_file" ]] && command -v sqlite3 >/dev/null 2>&1; then
external_port=$(runInstallOp sqlite3 "$docker_dir/$db_file" "SELECT resource_value FROM network_resources WHERE app_name = '$app_name' AND resource_type = 'port' AND service_name LIKE '%webui%' AND status = 'active' ORDER BY service_name LIMIT 1;" 2>/dev/null | cut -d':' -f1)
fi
local host="${public_ip_v4:-localhost}"
# Direct host-port URL: prefer the LAN address. $public_ip_v4 is the WAN
# address from an external resolver, and LibrePortal never forwards ports —
# so http://<wan-ip>:<port> is unreachable for the LAN/VPN clients this
# branch exists to serve, and bakes an off-network host into the app's own
# links. Falls back to the old value only if the route lookup found nothing.
local host="${local_ip_v4:-${public_ip_v4:-localhost}}"
if [[ -n "$external_port" ]]; then
app_url="http://$host:$external_port"
else

View File

@ -78,6 +78,64 @@ _instanceSetSubdomain() {
updateConfigOption "$key" "$newval" "$file" >/dev/null
}
# Rewrite the parent-service column (field 1) of every PORT_<n> row in the cloned
# config so it names the service _instanceRewriteCompose actually stamped into the
# compose.
#
# portAllocate stores this value as the port row's `parent_service`, and the WebUI
# joins ports to an app's Docker services on it (webui_services.sh,
# db_list_installed_app.sh) — matching against service names read back from the
# compose's SERVICE_TAG_<n>. The config re-namespace only rewrites KEYS, so an
# instance kept the TYPE's service name in the value while its compose moved on:
# the join found nothing and the instance rendered in the WebUI with no port, no
# URL and no login row, despite being up and reachable.
#
# MUST mirror rule 1 of _instanceRewriteCompose — same two token forms, so the
# config and the compose can't drift apart. Scoped to PORT_ lines so a bare app
# name elsewhere in the config (titles, descriptions, the subdomain column) is
# left alone.
_instanceRewriteConfigPorts() {
local type="$1" slug="$2" file="$3"
[[ -f "$file" ]] || return 0
sed -i -E "/^CFG_[A-Z0-9_]+_PORT_[0-9]+=/{
s/\b${type}-service\b/${slug}-service/g
s/\b${type}_db\b/${slug}_db/g
}" "$file"
}
# Force every one of the instance's ports to LAN-only: access `private`, Traefik
# off. Used by `instance create --local`, for a second copy that should be reached
# at http://<ip>:<port> and never given a router or a certificate.
#
# `disabled` ports are left as-is — the user opted them out of host exposure
# entirely, and flipping them to private would start publishing them.
_instanceSetLocalOnly() {
local slug_u="$1" file="$2"
local key line val newval
local -a f
local n
for n in {1..20}; do
key="CFG_${slug_u}_PORT_${n}"
line=$(grep -E "^${key}=" "$file" | head -n1)
[[ -z "$line" ]] && continue
val="${line#*=}"
val="${val//$'\r'/}"
val="${val#\"}"
val="${val%\"}"
local IFS='|'
f=($val)
unset IFS
while [[ ${#f[@]} -lt 11 ]]; do f+=(""); done
[[ "${f[3]}" == "disabled" ]] && continue
f[3]="private" # access
f[6]="false" # traefik
local IFS='|'
newval="${f[*]}"
unset IFS
updateConfigOption "$key" "$newval" "$file" >/dev/null
done
}
# Rewrite identity-bearing tokens in the cloned compose so the instance's
# containers, Traefik routers and backup labels are unique. image: lines are
# deliberately left untouched (rule 2/3 anchor on their line prefix; the
@ -146,9 +204,15 @@ _instanceRewriteTools() {
}
# Provision and install a new instance of a multi-instance-capable app.
# instanceCreate <type> <name> [domain_index] [subdomain]
# instanceCreate <type> <name> [domain_index] [subdomain] [local_only]
#
# local_only=true skips Traefik entirely — the instance is served on its own
# published host port and nothing else. That is also what you get implicitly when
# no CFG_DOMAIN_<n> is configured (initializeAppVariables forces the same thing),
# so the flag exists for the case where a domain IS set but this particular
# instance should stay off it.
instanceCreate() {
local type="$1" rawname="$2" domain_idx="$3" subdomain="$4"
local type="$1" rawname="$2" domain_idx="$3" subdomain="$4" local_only="$5"
local type_dir="${install_containers_dir%/}/$type"
if [[ -z "$type" || ! -d "$type_dir" || ! -f "$type_dir/$type.config" ]]; then
@ -180,7 +244,11 @@ instanceCreate() {
# Default the host to a hyphen-safe form of the slug; let the caller override.
[[ -z "$subdomain" ]] && subdomain="${slug//_/-}"
isNotice "Creating new '$type' instance '$id' (slug: $slug, host: ${subdomain}.<domain>)"
if [[ "$local_only" == "true" ]]; then
isNotice "Creating new '$type' instance '$id' (slug: $slug, LAN-only on a published port)"
else
isNotice "Creating new '$type' instance '$id' (slug: $slug, host: ${subdomain}.<domain>)"
fi
# 1. Clone the type's template tree into a new instance template.
local inst_dir="${install_containers_dir%/}/$slug"
@ -220,9 +288,17 @@ instanceCreate() {
[[ -n "$domain_idx" ]] && _instanceSetCfg "CFG_${slug_u}_DOMAIN" "$domain_idx" "$cfg"
_instanceSetSubdomain "$slug_u" "$subdomain" "$cfg"
# 4. Make the cloned compose + tools target the instance's own identity.
_instanceRewriteCompose "$type" "$slug" "$inst_dir"
_instanceRewriteTools "$type" "$slug" "$inst_dir"
# 4. Make the cloned compose + config ports + tools target the instance's own
# identity. The config ports must follow the compose or the WebUI can't
# join the instance's ports to its services.
_instanceRewriteCompose "$type" "$slug" "$inst_dir"
_instanceRewriteConfigPorts "$type" "$slug" "$cfg"
_instanceRewriteTools "$type" "$slug" "$inst_dir"
if [[ "$local_only" == "true" ]]; then
_instanceSetLocalOnly "$slug_u" "$cfg"
isNotice "Instance '$slug' is LAN-only — no Traefik router, reachable on its published host port."
fi
isSuccessful "Instance template ready: $slug (instance of $type)"

View File

@ -146,6 +146,23 @@ initializeAppVariables()
healthcheck=true
fi
# No domain configured -> no Traefik, whatever the per-port column says.
# LibrePortal is LAN/VPN-first: a box with CFG_DOMAIN_<n> unset must still
# serve every app on http://<ip>:<port>. Left alone, a traefik=true port with
# an empty $domain_full stamps Host(`<app>.`) — a trailing-dot host that
# matches nothing — and drags APP_URL to https://<app>. with it, breaking any
# app that builds its links from APP_URL (Bookstack, Nextcloud, Mastodon).
# Forcing the column false makes tagsProcessorPortRouterBlocks comment the
# router out entirely and lets tagsProcessorAppUrl fall through to the
# http://<ip>:<port> branch. The published host port is unaffected — access
# type, not the traefik flag, decides whether a port is allocated.
if [[ -z "$domain_full" ]]; then
local _nd
for _nd in "${!port_traefik_managed[@]}"; do
port_traefik_managed[$_nd]="false"
done
fi
# $public is derived from the port config — true iff any of this app's
# PORT_<n> rows have field 7 (traefik) == "true". Replaces the legacy
# CFG_<APP>_PUBLIC field, which was redundant with the per-port flag.
@ -174,4 +191,10 @@ initializeAppVariables()
host_setup="${_sub}.${domain_full}"
fi
fi
# Every branch above suffixes $domain_full, so with no domain configured they
# all yield a bare trailing dot ("bookstack."). That string is not a host, and
# it reaches DOMAINSUBNAME_TAG and the trusted-domains list regardless of the
# Traefik flag — so blank it rather than let a non-resolving name ship. Both
# consumers already treat empty as "no canonical host".
[[ -z "$domain_full" ]] && host_setup=""
}

View File

@ -583,8 +583,10 @@ declare -gA LP_FN_MAP=(
[instanceList]="instance/instance_create.sh"
[instanceRemove]="instance/instance_create.sh"
[_instanceRewriteCompose]="instance/instance_create.sh"
[_instanceRewriteConfigPorts]="instance/instance_create.sh"
[_instanceRewriteTools]="instance/instance_create.sh"
[_instanceSetCfg]="instance/instance_create.sh"
[_instanceSetLocalOnly]="instance/instance_create.sh"
[_instanceSetSubdomain]="instance/instance_create.sh"
[instanceTypeCfg]="instance/instance_create.sh"
[_invidiousBcrypt]="invidious/scripts/invidious_auth.sh"
@ -1731,8 +1733,10 @@ declare -gA LP_FN_ROOT=(
[instanceList]="scripts"
[instanceRemove]="scripts"
[_instanceRewriteCompose]="scripts"
[_instanceRewriteConfigPorts]="scripts"
[_instanceRewriteTools]="scripts"
[_instanceSetCfg]="scripts"
[_instanceSetLocalOnly]="scripts"
[_instanceSetSubdomain]="scripts"
[instanceTypeCfg]="scripts"
[_invidiousBcrypt]="containers"
@ -2915,8 +2919,10 @@ instanceIdPart() { unset -f instanceIdPart; __lpAutoload "${install_scripts_dir}
instanceList() { unset -f instanceList; __lpAutoload "${install_scripts_dir}instance/instance_create.sh"; instanceList "$@"; }
instanceRemove() { unset -f instanceRemove; __lpAutoload "${install_scripts_dir}instance/instance_create.sh"; instanceRemove "$@"; }
_instanceRewriteCompose() { unset -f _instanceRewriteCompose; __lpAutoload "${install_scripts_dir}instance/instance_create.sh"; _instanceRewriteCompose "$@"; }
_instanceRewriteConfigPorts() { unset -f _instanceRewriteConfigPorts; __lpAutoload "${install_scripts_dir}instance/instance_create.sh"; _instanceRewriteConfigPorts "$@"; }
_instanceRewriteTools() { unset -f _instanceRewriteTools; __lpAutoload "${install_scripts_dir}instance/instance_create.sh"; _instanceRewriteTools "$@"; }
_instanceSetCfg() { unset -f _instanceSetCfg; __lpAutoload "${install_scripts_dir}instance/instance_create.sh"; _instanceSetCfg "$@"; }
_instanceSetLocalOnly() { unset -f _instanceSetLocalOnly; __lpAutoload "${install_scripts_dir}instance/instance_create.sh"; _instanceSetLocalOnly "$@"; }
_instanceSetSubdomain() { unset -f _instanceSetSubdomain; __lpAutoload "${install_scripts_dir}instance/instance_create.sh"; _instanceSetSubdomain "$@"; }
instanceTypeCfg() { unset -f instanceTypeCfg; __lpAutoload "${install_scripts_dir}instance/instance_create.sh"; instanceTypeCfg "$@"; }
_invidiousBcrypt() { unset -f _invidiousBcrypt; __lpAutoload "${install_containers_dir}invidious/scripts/invidious_auth.sh"; _invidiousBcrypt "$@"; }

View File

@ -34,6 +34,21 @@ fi
if [[ -z "$public_ip_v4" ]]; then
public_ip_v4="localhost"
fi
# This host's LAN address — the source IP the kernel picks for the default route.
# Deliberately separate from $public_ip_v4 above, which prefers the WAN address an
# external resolver sees. Anything advertising a *directly published host port*
# (APP_URL, printed logins, the WebUI's service URLs) must use this one: LibrePortal
# never port-forwards, so the WAN address only ever resolves for someone who set up
# forwarding by hand, while the LAN/VPN address is what clients actually dial.
local_ip_v4="$(ip -4 route get 1.1.1.1 2>/dev/null | grep -Po '(?<=src )(\S+)' | head -1)"
if [[ -z "$local_ip_v4" ]]; then
local_ip_v4=$(hostname -I | awk '{print $1}' 2>/dev/null)
fi
if [[ -z "$local_ip_v4" ]]; then
local_ip_v4="localhost"
fi
server_nic="$(ip -4 route ls | grep default | grep -Po '(?<=dev )(\S+)' | head -1)"
default_subnet="10.100.0"