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:
parent
63b3af4cfc
commit
e25c69e2a1
@ -686,6 +686,41 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
min-height: 14px;
|
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 {
|
.lp-instance-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|||||||
@ -61,9 +61,14 @@ class InstanceManager {
|
|||||||
const title = this._typeTitle(typeSlug);
|
const title = this._typeTitle(typeSlug);
|
||||||
this.domains = await this._loadDomains();
|
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('')
|
? 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');
|
const overlay = document.createElement('div');
|
||||||
overlay.id = 'lp-instance-modal';
|
overlay.id = 'lp-instance-modal';
|
||||||
@ -74,7 +79,7 @@ class InstanceManager {
|
|||||||
<h3>New ${this._esc(title)} instance</h3>
|
<h3>New ${this._esc(title)} instance</h3>
|
||||||
<button type="button" class="lp-instance-x" aria-label="Close">×</button>
|
<button type="button" class="lp-instance-x" aria-label="Close">×</button>
|
||||||
</div>
|
</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">
|
<label class="lp-instance-field">
|
||||||
<span>Instance name</span>
|
<span>Instance name</span>
|
||||||
@ -82,7 +87,13 @@ class InstanceManager {
|
|||||||
<small id="lp-instance-slug" class="lp-instance-hint"></small>
|
<small id="lp-instance-slug" class="lp-instance-hint"></small>
|
||||||
</label>
|
</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">
|
<label class="lp-instance-field">
|
||||||
<span>Domain</span>
|
<span>Domain</span>
|
||||||
<select id="lp-instance-domain" class="form-control">${domainOptions}</select>
|
<select id="lp-instance-domain" class="form-control">${domainOptions}</select>
|
||||||
@ -110,6 +121,8 @@ class InstanceManager {
|
|||||||
const subEl = overlay.querySelector('#lp-instance-subdomain');
|
const subEl = overlay.querySelector('#lp-instance-subdomain');
|
||||||
const domEl = overlay.querySelector('#lp-instance-domain');
|
const domEl = overlay.querySelector('#lp-instance-domain');
|
||||||
const hostEl = overlay.querySelector('#lp-instance-host');
|
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');
|
const createBtn = overlay.querySelector('.lp-instance-create');
|
||||||
|
|
||||||
let subEdited = false;
|
let subEdited = false;
|
||||||
@ -120,14 +133,25 @@ class InstanceManager {
|
|||||||
const slug = id ? `${typeSlug}_${id}` : '';
|
const slug = id ? `${typeSlug}_${id}` : '';
|
||||||
slugEl.textContent = slug ? `Created as ${slug}` : 'Letters and numbers only';
|
slugEl.textContent = slug ? `Created as ${slug}` : 'Letters and numbers only';
|
||||||
if (!subEdited) subEl.value = slug ? slug.replace(/_/g, '-') : '';
|
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();
|
const isLocal = localEl.checked;
|
||||||
hostEl.textContent = sub ? `${sub}.${dom}` : `…`;
|
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;
|
createBtn.disabled = !id;
|
||||||
};
|
};
|
||||||
nameEl.addEventListener('input', refresh);
|
nameEl.addEventListener('input', refresh);
|
||||||
domEl.addEventListener('change', refresh);
|
domEl.addEventListener('change', refresh);
|
||||||
subEl.addEventListener('input', refresh);
|
subEl.addEventListener('input', refresh);
|
||||||
|
localEl.addEventListener('change', refresh);
|
||||||
refresh();
|
refresh();
|
||||||
setTimeout(() => nameEl.focus(), 30);
|
setTimeout(() => nameEl.focus(), 30);
|
||||||
|
|
||||||
@ -137,10 +161,12 @@ class InstanceManager {
|
|||||||
overlay.querySelector('.lp-instance-cancel').addEventListener('click', () => this.close());
|
overlay.querySelector('.lp-instance-cancel').addEventListener('click', () => this.close());
|
||||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) 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);
|
const id = this._idPart(name);
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
const slug = `${typeSlug}_${id}`;
|
const slug = `${typeSlug}_${id}`;
|
||||||
@ -165,8 +191,11 @@ class InstanceManager {
|
|||||||
await window.tasksManager.router.routeAction('instance_create', {
|
await window.tasksManager.router.routeAction('instance_create', {
|
||||||
type: typeSlug,
|
type: typeSlug,
|
||||||
name: name,
|
name: name,
|
||||||
domainIndex: domainIndex,
|
// Domain/subdomain are meaningless for a LAN-only instance and would
|
||||||
subdomain: subdomain
|
// otherwise be passed through to a config the --local pass then overwrites.
|
||||||
|
domainIndex: localOnly ? '' : domainIndex,
|
||||||
|
subdomain: localOnly ? '' : subdomain,
|
||||||
|
localOnly: !!localOnly
|
||||||
});
|
});
|
||||||
this.close();
|
this.close();
|
||||||
notify(`Creating instance ${slug} — track progress in Tasks.`, 'success');
|
notify(`Creating instance ${slug} — track progress in Tasks.`, 'success');
|
||||||
|
|||||||
@ -34,12 +34,16 @@ class TaskActions {
|
|||||||
* like any install (button state, task icon, progress), while the verbatim
|
* like any install (button state, task icon, progress), while the verbatim
|
||||||
* command carries the optional domain#/subdomain through executeTask.
|
* command carries the optional domain#/subdomain through executeTask.
|
||||||
*/
|
*/
|
||||||
async instanceCreate(type, name, domainIndex = '', subdomain = '') {
|
async instanceCreate(type, name, domainIndex = '', subdomain = '', localOnly = false) {
|
||||||
try {
|
try {
|
||||||
this.commands.validateCommand('instance_create', { type, name });
|
this.commands.validateCommand('instance_create', { type, name });
|
||||||
const parts = ['libreportal', 'instance', 'create', type, name];
|
const parts = ['libreportal', 'instance', 'create', type, name];
|
||||||
if (domainIndex) parts.push(domainIndex);
|
if (domainIndex) parts.push(domainIndex);
|
||||||
if (subdomain) parts.push(subdomain);
|
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
|
// The new instance's slug — mirrors the backend's <type>_<id> scheme so
|
||||||
// monitorTask/highlight track the right app once it appears.
|
// monitorTask/highlight track the right app once it appears.
|
||||||
const id = String(name).toLowerCase().replace(/[^a-z0-9]/g, '');
|
const id = String(name).toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
|||||||
@ -22,7 +22,7 @@ class TaskRouter {
|
|||||||
return await this.actions.installApp(params.appName, params.config, params.resetNetwork);
|
return await this.actions.installApp(params.appName, params.config, params.resetNetwork);
|
||||||
|
|
||||||
case 'instance_create':
|
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':
|
case 'instance_remove':
|
||||||
return await this.actions.instanceRemove(params.appName);
|
return await this.actions.instanceRemove(params.appName);
|
||||||
|
|||||||
@ -9,24 +9,43 @@
|
|||||||
cliHandleInstanceCommands()
|
cliHandleInstanceCommands()
|
||||||
{
|
{
|
||||||
local action="$initial_command2"
|
local action="$initial_command2"
|
||||||
local type="$initial_command3"
|
|
||||||
local name="$initial_command4"
|
# --local/--lan is pulled out of the positional list rather than given a fixed
|
||||||
local domain_idx="$initial_command5"
|
# slot, so it can be written anywhere after the verb — `instance create
|
||||||
local subdomain="$initial_command6"
|
# 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
|
case "$action" in
|
||||||
"create")
|
"create")
|
||||||
if [[ -z "$type" || -z "$name" ]]; then
|
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
|
cliShowInstanceHelp
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
|
if [[ "$LIBREPORTAL_TASK_EXEC" == "1" ]]; then
|
||||||
instanceCreate "$type" "$name" "$domain_idx" "$subdomain"
|
instanceCreate "$type" "$name" "$domain_idx" "$subdomain" "$local_only"
|
||||||
else
|
else
|
||||||
local _cmd="libreportal instance create $type $name"
|
local _cmd="libreportal instance create $type $name"
|
||||||
[[ -n "$domain_idx" ]] && _cmd+=" $domain_idx"
|
[[ -n "$domain_idx" ]] && _cmd+=" $domain_idx"
|
||||||
[[ -n "$subdomain" ]] && _cmd+=" $subdomain"
|
[[ -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")"
|
cliTaskRun "$_cmd" "install" "${type}_$(instanceIdPart "$name")"
|
||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
|
|||||||
@ -13,12 +13,15 @@ cliShowInstanceHelp()
|
|||||||
echo " its own data, DB, subdomain, backups and update cadence. Only apps"
|
echo " its own data, DB, subdomain, backups and update cadence. Only apps"
|
||||||
echo " with CFG_<TYPE>_MULTI_INSTANCE=true can be instanced."
|
echo " with CFG_<TYPE>_MULTI_INSTANCE=true can be instanced."
|
||||||
echo ""
|
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 " - Provision + install a new instance."
|
||||||
echo " type = base app slug (e.g. bookstack)"
|
echo " type = base app slug (e.g. bookstack)"
|
||||||
echo " name - instance name (e.g. blog)"
|
echo " name - instance name (e.g. blog)"
|
||||||
echo " domain# - which CFG_DOMAIN_n to route on (default 1)"
|
echo " domain# - which CFG_DOMAIN_n to route on (default 1)"
|
||||||
echo " subdomain- host label (default <type>-<name>)"
|
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 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 " libreportal instance list [type] - List instances (all, or just for one app type)"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@ -97,7 +97,7 @@ dockerConfigSetupFileWithData()
|
|||||||
ipUpdateComposeTags "$app_name" "$full_file_path"
|
ipUpdateComposeTags "$app_name" "$full_file_path"
|
||||||
portUpdateComposeTags "$app_name" "$full_file_path"
|
portUpdateComposeTags "$app_name" "$full_file_path"
|
||||||
tagsProcessorTrustedDomains "$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
|
# Mail Server Settings
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
#!/bin/bash
|
#!/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()
|
tagsProcessorAppUrl()
|
||||||
{
|
{
|
||||||
local full_file_path="$1"
|
local full_file_path="$1"
|
||||||
@ -7,6 +11,7 @@ tagsProcessorAppUrl()
|
|||||||
local is_public="$3"
|
local is_public="$3"
|
||||||
local host_setup="$4"
|
local host_setup="$4"
|
||||||
local public_ip_v4="$5"
|
local public_ip_v4="$5"
|
||||||
|
local local_ip_v4="$6"
|
||||||
|
|
||||||
local traefik_installed=""
|
local traefik_installed=""
|
||||||
if [[ -f "$docker_dir/$db_file" ]] && command -v sqlite3 >/dev/null 2>&1; then
|
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
|
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)
|
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
|
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
|
if [[ -n "$external_port" ]]; then
|
||||||
app_url="http://$host:$external_port"
|
app_url="http://$host:$external_port"
|
||||||
else
|
else
|
||||||
|
|||||||
@ -78,6 +78,64 @@ _instanceSetSubdomain() {
|
|||||||
updateConfigOption "$key" "$newval" "$file" >/dev/null
|
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
|
# Rewrite identity-bearing tokens in the cloned compose so the instance's
|
||||||
# containers, Traefik routers and backup labels are unique. image: lines are
|
# containers, Traefik routers and backup labels are unique. image: lines are
|
||||||
# deliberately left untouched (rule 2/3 anchor on their line prefix; the
|
# 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.
|
# 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() {
|
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"
|
local type_dir="${install_containers_dir%/}/$type"
|
||||||
if [[ -z "$type" || ! -d "$type_dir" || ! -f "$type_dir/$type.config" ]]; then
|
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.
|
# Default the host to a hyphen-safe form of the slug; let the caller override.
|
||||||
[[ -z "$subdomain" ]] && subdomain="${slug//_/-}"
|
[[ -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.
|
# 1. Clone the type's template tree into a new instance template.
|
||||||
local inst_dir="${install_containers_dir%/}/$slug"
|
local inst_dir="${install_containers_dir%/}/$slug"
|
||||||
@ -220,9 +288,17 @@ instanceCreate() {
|
|||||||
[[ -n "$domain_idx" ]] && _instanceSetCfg "CFG_${slug_u}_DOMAIN" "$domain_idx" "$cfg"
|
[[ -n "$domain_idx" ]] && _instanceSetCfg "CFG_${slug_u}_DOMAIN" "$domain_idx" "$cfg"
|
||||||
_instanceSetSubdomain "$slug_u" "$subdomain" "$cfg"
|
_instanceSetSubdomain "$slug_u" "$subdomain" "$cfg"
|
||||||
|
|
||||||
# 4. Make the cloned compose + tools target the instance's own identity.
|
# 4. Make the cloned compose + config ports + tools target the instance's own
|
||||||
_instanceRewriteCompose "$type" "$slug" "$inst_dir"
|
# identity. The config ports must follow the compose or the WebUI can't
|
||||||
_instanceRewriteTools "$type" "$slug" "$inst_dir"
|
# 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)"
|
isSuccessful "Instance template ready: $slug (instance of $type)"
|
||||||
|
|
||||||
|
|||||||
@ -146,6 +146,23 @@ initializeAppVariables()
|
|||||||
healthcheck=true
|
healthcheck=true
|
||||||
fi
|
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
|
# $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
|
# PORT_<n> rows have field 7 (traefik) == "true". Replaces the legacy
|
||||||
# CFG_<APP>_PUBLIC field, which was redundant with the per-port flag.
|
# CFG_<APP>_PUBLIC field, which was redundant with the per-port flag.
|
||||||
@ -174,4 +191,10 @@ initializeAppVariables()
|
|||||||
host_setup="${_sub}.${domain_full}"
|
host_setup="${_sub}.${domain_full}"
|
||||||
fi
|
fi
|
||||||
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=""
|
||||||
}
|
}
|
||||||
|
|||||||
@ -583,8 +583,10 @@ declare -gA LP_FN_MAP=(
|
|||||||
[instanceList]="instance/instance_create.sh"
|
[instanceList]="instance/instance_create.sh"
|
||||||
[instanceRemove]="instance/instance_create.sh"
|
[instanceRemove]="instance/instance_create.sh"
|
||||||
[_instanceRewriteCompose]="instance/instance_create.sh"
|
[_instanceRewriteCompose]="instance/instance_create.sh"
|
||||||
|
[_instanceRewriteConfigPorts]="instance/instance_create.sh"
|
||||||
[_instanceRewriteTools]="instance/instance_create.sh"
|
[_instanceRewriteTools]="instance/instance_create.sh"
|
||||||
[_instanceSetCfg]="instance/instance_create.sh"
|
[_instanceSetCfg]="instance/instance_create.sh"
|
||||||
|
[_instanceSetLocalOnly]="instance/instance_create.sh"
|
||||||
[_instanceSetSubdomain]="instance/instance_create.sh"
|
[_instanceSetSubdomain]="instance/instance_create.sh"
|
||||||
[instanceTypeCfg]="instance/instance_create.sh"
|
[instanceTypeCfg]="instance/instance_create.sh"
|
||||||
[_invidiousBcrypt]="invidious/scripts/invidious_auth.sh"
|
[_invidiousBcrypt]="invidious/scripts/invidious_auth.sh"
|
||||||
@ -1731,8 +1733,10 @@ declare -gA LP_FN_ROOT=(
|
|||||||
[instanceList]="scripts"
|
[instanceList]="scripts"
|
||||||
[instanceRemove]="scripts"
|
[instanceRemove]="scripts"
|
||||||
[_instanceRewriteCompose]="scripts"
|
[_instanceRewriteCompose]="scripts"
|
||||||
|
[_instanceRewriteConfigPorts]="scripts"
|
||||||
[_instanceRewriteTools]="scripts"
|
[_instanceRewriteTools]="scripts"
|
||||||
[_instanceSetCfg]="scripts"
|
[_instanceSetCfg]="scripts"
|
||||||
|
[_instanceSetLocalOnly]="scripts"
|
||||||
[_instanceSetSubdomain]="scripts"
|
[_instanceSetSubdomain]="scripts"
|
||||||
[instanceTypeCfg]="scripts"
|
[instanceTypeCfg]="scripts"
|
||||||
[_invidiousBcrypt]="containers"
|
[_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 "$@"; }
|
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 "$@"; }
|
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 "$@"; }
|
_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 "$@"; }
|
_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 "$@"; }
|
_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 "$@"; }
|
_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 "$@"; }
|
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 "$@"; }
|
_invidiousBcrypt() { unset -f _invidiousBcrypt; __lpAutoload "${install_containers_dir}invidious/scripts/invidious_auth.sh"; _invidiousBcrypt "$@"; }
|
||||||
|
|||||||
15
variables.sh
15
variables.sh
@ -34,6 +34,21 @@ fi
|
|||||||
if [[ -z "$public_ip_v4" ]]; then
|
if [[ -z "$public_ip_v4" ]]; then
|
||||||
public_ip_v4="localhost"
|
public_ip_v4="localhost"
|
||||||
fi
|
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)"
|
server_nic="$(ip -4 route ls | grep default | grep -Po '(?<=dev )(\S+)' | head -1)"
|
||||||
default_subnet="10.100.0"
|
default_subnet="10.100.0"
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user