#!/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 `. # # 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) }