LibrePortal/scripts/database/tables/db_create_tables.sh
librelad ff25b08ee8 Make multi-instance actually install, and stop apps stealing each other's network rows
Instance install (bugs found by running one end to end):

- The cloned compose kept the TYPE's tag namespace
  (#LIBREPORTAL|BOOKSTACK_APP_KEY_1_TAG|...) while the config had been
  re-namespaced to CFG_<SLUG>_*, so tagsProcessorAppConfigValues matched
  nothing, the placeholders survived and the pre-start guard refused to
  launch. Rewrite the tag names and *_DATA tokens too — narrowly, so an
  app whose compose sets a real env var named after itself is untouched.
- Tools/hooks kept uppercase CFG_<TYPE>_ reads, so an instance
  provisioned itself from the type's config and ignored its own values.
- Cloned hooks were never loaded: both loaders run at startup, before the
  instance dir exists, so _appCallHook's `declare -F` found nothing and
  every <slug>_install_* hook silently no-opped — for bookstack that is
  the readiness probe and the admin bootstrap. Source the instance's own
  scripts in-process, then regen arrays + manifest for later runs.
- bookstack's hook hardcoded the container name after `docker exec -e ...`
  flags, where the rewriter can't see it, so an instance's admin bootstrap
  ran against the BASE app's container — including a tinker DELETE of a
  user. Target "$app_name" instead, and teach the rewriter the
  container="<type>" assignment form used by auth adapters.

network_resources uniqueness:

UNIQUE(resource_type, resource_value) is right for 'ip' and 'port' but the
port-tag writer stores descriptive rows in the same table with INSERT OR
REPLACE, so every install DELETED the matching row from whichever app held
it. traefik_managed and url_accessible are booleans, so the whole table
could only ever hold one row of each. Observed live: installing a second
bookstack took all four traefik_managed/url_accessible rows from stoat and
bookstack, and removing that instance took the stolen rows with it.

Replace it with a partial unique index scoped to ip/port, and migrate
existing databases in place (SQLite can't drop a constraint, so the table
is rebuilt inside a transaction). The migration is invoked from
portUpdateComposeTags, not just databaseCreateTables — the latter only
runs from startPreInstall, which a working install never re-runs.

Verified: two bookstacks now hold port_tag_internal=80, traefik_managed
and url_accessible simultaneously; duplicate host ports and IPs are still
rejected; instance installs, serves HTTP 200, provisions its own admin in
its own database, and removes cleanly with no orphan rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 23:15:21 +01:00

201 lines
11 KiB
Bash
Executable File

#!/bin/bash
databaseCreateTables()
{
if command -v sqlite3 &> /dev/null; then
setup_table_name=path
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
# Table info here
local result; result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name (path TEXT);")
checkSuccess "Creating $setup_table_name table"
fi
setup_table_name=sysupdate
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
# Table info here
local result; result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name (date DATE, time TIME);")
checkSuccess "Creating $setup_table_name table"
fi
setup_table_name=options
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
# Table info here
local result; result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name (option TEXT UNIQUE, content TEXT);")
checkSuccess "Creating $setup_table_name table"
fi
setup_table_name=apps
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
# Table info here
# status = 1 = installed, 0 uninstalled
local result; result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name (name TEXT UNIQUE, status DATE, install_date DATE, install_time TIME, uninstall_date DATE, uninstall_time TIME);")
checkSuccess "Creating $setup_table_name table"
fi
setup_table_name=backups
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
# Table info here
local result; result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, date DATE, time TIME);")
checkSuccess "Creating $setup_table_name table"
fi
setup_table_name=restores
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
# Table info here
local result; result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, date DATE, time TIME);")
checkSuccess "Creating $setup_table_name table"
fi
setup_table_name=migrations
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
# Table info here
local result; result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT, date DATE, time TIME);")
checkSuccess "Creating $setup_table_name table"
fi
setup_table_name=ssh
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
# Table info here
local result; result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name (id INTEGER PRIMARY KEY AUTOINCREMENT, ip TEXT, date DATE, time TIME);")
checkSuccess "Creating $setup_table_name table"
fi
setup_table_name=ssh_keys
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
# Table info here
local result; result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name (name TEXT UNIQUE, hash TEXT, date DATE, time TIME);")
checkSuccess "Creating $setup_table_name table"
fi
setup_table_name=peers
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
# Named other LibrePortal instances. kind selects the transport:
# backup-channel Phase 1/2 — friendly label over a hostname
# that already shows up in a shared backup repo
# direct-ssh-direct Phase 3 — reachable peer over plain SSH
# direct-ssh-via-relay Phase 3b — peer over Connect's blind relay
# config_json carries kind-specific knobs (hostname, loc_idx, pubkey
# fingerprint, relay token, etc.) so adding new kinds doesn't need
# another schema migration.
local result; result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
kind TEXT NOT NULL DEFAULT 'backup-channel',
config_json TEXT NOT NULL DEFAULT '{}',
status TEXT DEFAULT 'unknown',
last_seen TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP
);")
checkSuccess "Creating $setup_table_name table"
local result; result=$(sqlite3 "$docker_dir/$db_file" "CREATE INDEX IF NOT EXISTS idx_peers_name ON peers(name);")
local result; result=$(sqlite3 "$docker_dir/$db_file" "CREATE INDEX IF NOT EXISTS idx_peers_kind ON peers(kind);")
fi
setup_table_name=network_resources
if ! sqlite3 "$docker_dir/$db_file" ".tables" | grep -q "\b$setup_table_name\b"; then
# Simple unified network resources table - replaces all complex network tables
local result; result=$(sqlite3 $docker_dir/$db_file "CREATE TABLE IF NOT EXISTS $setup_table_name (
id INTEGER PRIMARY KEY AUTOINCREMENT,
app_name TEXT NOT NULL,
resource_type TEXT NOT NULL, -- 'ip' or 'port'
resource_value TEXT NOT NULL, -- '172.20.0.10' or '3001'
service_name TEXT DEFAULT 'main', -- service within app
parent_service TEXT DEFAULT NULL, -- parent Docker service for ports
status TEXT DEFAULT 'active',
created_date DATE DEFAULT CURRENT_DATE,
created_time TIME DEFAULT CURRENT_TIME,
UNIQUE(app_name, resource_type, service_name)
);")
checkSuccess "Creating unified network_resources table"
# Create simple indexes for performance
local result; result=$(sqlite3 "$docker_dir/$db_file" "CREATE INDEX IF NOT EXISTS idx_network_resources_app ON network_resources(app_name);")
checkSuccess "Creating network resources app index"
local result; result=$(sqlite3 "$docker_dir/$db_file" "CREATE INDEX IF NOT EXISTS idx_network_resources_type ON network_resources(resource_type);")
checkSuccess "Creating network resources type index"
local result; result=$(sqlite3 "$docker_dir/$db_file" "CREATE INDEX IF NOT EXISTS idx_network_resources_value ON network_resources(resource_value);")
checkSuccess "Creating network resources value index"
local result; result=$(sqlite3 "$docker_dir/$db_file" "CREATE INDEX IF NOT EXISTS idx_network_resources_status ON network_resources(status);")
checkSuccess "Creating network resources status index"
local result; result=$(sqlite3 "$docker_dir/$db_file" "CREATE INDEX IF NOT EXISTS idx_network_resources_parent_service ON network_resources(parent_service);")
checkSuccess "Creating network resources parent service index"
fi
# Runs for fresh AND existing databases (this function is re-run on every
# startup), so an install created before the constraint was scoped is
# repaired in place.
databaseMigrateNetworkResourcesUnique
else
echo "SQLite3 is not installed. Skipping table creation."
fi
}
# Scope the global-uniqueness constraint on network_resources to the resources
# that are actually globally unique.
#
# The table shipped with UNIQUE(resource_type, resource_value). That is right for
# 'ip' and 'port' — no two apps may hold the same host port or container IP — but
# the port-tag writer stores descriptive rows in the same table and writes them
# with INSERT OR REPLACE, so each install DELETED the matching row from whichever
# app held it:
# port_tag_internal the CONTAINER-side port: every web app uses 80
# traefik_managed 'true'/'false' — two possible values, so the whole table
# url_accessible could only ever hold ONE row of each, for one app
# Consequences: the firewall rebuild's traefik_managed LEFT JOIN reads NULL for
# every app but the last one installed, and in rooted mode a stolen
# port_tag_internal makes ufw-docker fall back to the EXTERNAL port — the exact
# "cannot find the published port" failure firewall_rebuild_from_db.sh warns
# about. Rows already lost are not reconstructable here; each app repopulates its
# own on the next `libreportal app install <app>`.
#
# SQLite cannot drop a table constraint, so this rebuilds the table when the
# legacy constraint is still present, then enforces the narrow rule as a partial
# unique index. Idempotent: once migrated, the guard skips it.
databaseMigrateNetworkResourcesUnique()
{
local table_sql
table_sql=$(sqlite3 "$docker_dir/$db_file" "SELECT sql FROM sqlite_master WHERE type='table' AND name='network_resources';" 2>/dev/null)
[[ -n "$table_sql" ]] || return 0
if [[ "$table_sql" == *"UNIQUE(resource_type, resource_value)"* ]]; then
# Single transaction: either the whole swap lands or the old table stays.
local result; result=$(sqlite3 "$docker_dir/$db_file" "
PRAGMA foreign_keys=off;
BEGIN TRANSACTION;
CREATE TABLE network_resources_migrated (
id INTEGER PRIMARY KEY AUTOINCREMENT,
app_name TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_value TEXT NOT NULL,
service_name TEXT DEFAULT 'main',
parent_service TEXT DEFAULT NULL,
status TEXT DEFAULT 'active',
created_date DATE DEFAULT CURRENT_DATE,
created_time TIME DEFAULT CURRENT_TIME,
UNIQUE(app_name, resource_type, service_name)
);
INSERT INTO network_resources_migrated
SELECT id, app_name, resource_type, resource_value, service_name,
parent_service, status, created_date, created_time
FROM network_resources;
DROP TABLE network_resources;
ALTER TABLE network_resources_migrated RENAME TO network_resources;
CREATE INDEX IF NOT EXISTS idx_network_resources_app ON network_resources(app_name);
CREATE INDEX IF NOT EXISTS idx_network_resources_type ON network_resources(resource_type);
CREATE INDEX IF NOT EXISTS idx_network_resources_value ON network_resources(resource_value);
CREATE INDEX IF NOT EXISTS idx_network_resources_status ON network_resources(status);
CREATE INDEX IF NOT EXISTS idx_network_resources_parent_service ON network_resources(parent_service);
COMMIT;
" 2>&1)
if [[ -n "$result" ]]; then
isNotice "network_resources migration reported: $result"
fi
checkSuccess "Scoping network_resources uniqueness to ip/port"
fi
# The narrow rule, as a partial index — still blocks two apps claiming the
# same host port or IP, and lets the descriptive rows coexist. Silent: it is
# a no-op on every startup after the first.
local result; result=$(sqlite3 "$docker_dir/$db_file" "CREATE UNIQUE INDEX IF NOT EXISTS idx_network_resources_global_value ON network_resources(resource_type, resource_value) WHERE resource_type IN ('ip','port');" 2>/dev/null)
}