Skip to content

Commit 36c1813

Browse files
Alexey PortnovAlexey Portnov
authored andcommitted
fix(firewall): serialize rule writes to kill SQLite lock races (#1020)
IptablesConf::updateFirewallRules() looped `$rule->update()` with no mutex or transaction, so ReloadFirewallAction running in WorkerModelsEvents raced with WorkerApiCommands writers and tripped "database is locked" (Sentry MIKOPBX-KMK/MIKOPBX-KMM, 24 events across 3+ installs on 2026.1.223). Route the batch through BaseActionHelper::executeInTransaction() so it takes the shared 'db-write' Redis mutex and runs in a single SQLite transaction. Rules are materialized first and the mutex/transaction is only entered when there is real work to do — the common no-op path stays free of Redis round-trips. Guard the upgrade path with isMutexAvailable(): UpdateSystemConfig calls updateFirewallRules() during release upgrades, when Redis may not yet be reachable. The probe forces a real Redis round-trip via isLocked(); on failure it logs a warning and falls back to direct per-row save(). Upgrade is single-threaded in that phase, so the fallback cannot race. Probe failures are not cached — transient Redis outages auto-recover on the next call. Also route Firewall SaveRecordAction through executeInTransaction(): it previously called $db->begin()/commit()/rollback() directly, without the 'db-write' mutex, so it contended with every other writer. Validation failures now raise \DomainException as a sentinel — the outer catch preserves the original API contract (individual Phalcon messages pushed to $res->messages['error'], no extra syslog noise), while infrastructure errors still flow through handleError(). Verified on boffart.miko.ru (2026.1.233-dev) with 5 parallel PHP stress workers × 15 iterations + 5 concurrent REST PATCH: - baseline (pre-fix): 1 ok / 74 fail, 74 PDOException database-locked - with fix: 75 ok / 0 fail, 0 locks, 5/5 REST 200 OK - fallback (Redis down): warning logged, direct writes succeeded Closes #1020
1 parent 3a8b3ab commit 36c1813

2 files changed

Lines changed: 172 additions & 90 deletions

File tree

src/Core/System/Configs/IptablesConf.php

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
namespace MikoPBX\Core\System\Configs;
2222

2323
use MikoPBX\Common\Models\{FirewallRules, NetworkFilters, PbxSettings, Sip};
24+
use MikoPBX\Common\Providers\MutexProvider;
2425
use MikoPBX\Common\Providers\PBXConfModulesProvider;
2526
use MikoPBX\Core\Asterisk\Configs\SIPConf;
2627
use MikoPBX\Core\System\Processes;
@@ -29,6 +30,8 @@
2930
use MikoPBX\Core\System\Util;
3031
use MikoPBX\Core\Utilities\IpAddressHelper;
3132
use MikoPBX\Modules\Config\SystemConfigInterface;
33+
use MikoPBX\PBXCoreREST\Lib\Common\BaseActionHelper;
34+
use Phalcon\Di\Di;
3235
use Phalcon\Di\Injectable;
3336

3437
/**
@@ -451,7 +454,16 @@ private function makeCmdMultiportBySubnet(array $options, array &$arr_command):
451454
}
452455

453456
/**
454-
* Updates firewall rules according to default template
457+
* Updates firewall rules according to default template.
458+
*
459+
* Normally runs inside the shared 'db-write' mutex + SQLite transaction so
460+
* the batch update cannot race with parallel API writes from
461+
* WorkerApiCommands (see GitHub #1020 and Sentry MIKOPBX-KMK/MIKOPBX-KMM).
462+
*
463+
* When MutexProvider/Redis is not yet reachable — as happens during early
464+
* boot and the release-upgrade path (UpdateSystemConfig) — this method
465+
* degrades to a direct per-row save without transaction. Upgrade code is
466+
* single-threaded in that phase, so there is no writer to race with.
455467
*/
456468
public static function updateFirewallRules(): void
457469
{
@@ -465,15 +477,80 @@ public static function updateFirewallRules(): void
465477
],
466478
];
467479
$rules = FirewallRules::find($conditions);
480+
481+
// Materialize only rules that actually change, so we skip
482+
// taking the mutex and opening a transaction on the common no-op path.
483+
$toUpdate = [];
468484
foreach ($rules as $rule) {
469485
$from = $portSet[$rule->portFromKey] ?? '0';
470-
$to = $portSet[$rule->portToKey] ?? '0';
486+
$to = $portSet[$rule->portToKey] ?? '0';
471487
if ($from === $rule->portfrom && $to === $rule->portto) {
472488
continue;
473489
}
474490
$rule->portfrom = $from;
475-
$rule->portto = $to;
476-
$rule->update();
491+
$rule->portto = $to;
492+
$toUpdate[] = $rule;
493+
}
494+
495+
if (empty($toUpdate)) {
496+
return;
497+
}
498+
499+
$applyUpdates = static function () use ($toUpdate): void {
500+
foreach ($toUpdate as $rule) {
501+
if (!$rule->save()) {
502+
$messages = array_map(
503+
static fn($m) => (string)$m->getMessage(),
504+
$rule->getMessages()
505+
);
506+
throw new \RuntimeException(
507+
'IptablesConf::updateFirewallRules failed: ' . implode(', ', $messages)
508+
);
509+
}
510+
}
511+
};
512+
513+
if (self::isMutexAvailable()) {
514+
BaseActionHelper::executeInTransaction($applyUpdates);
515+
return;
516+
}
517+
518+
// Fallback: early boot / release upgrade — Redis not ready yet.
519+
$applyUpdates();
520+
}
521+
522+
/**
523+
* Probe MutexProvider / Redis availability.
524+
*
525+
* Used by updateFirewallRules() to decide between the mutex-guarded path
526+
* (runtime) and the direct-write fallback (early boot / upgrade). A failure
527+
* here is not cached so transient Redis outages auto-recover on the next
528+
* call.
529+
*/
530+
private static function isMutexAvailable(): bool
531+
{
532+
try {
533+
$di = Di::getDefault();
534+
if ($di === null || !$di->has(MutexProvider::SERVICE_NAME)) {
535+
return false;
536+
}
537+
538+
/** @var MutexProvider $mutex */
539+
$mutex = $di->get(MutexProvider::SERVICE_NAME);
540+
541+
// Force a real Redis round-trip so providers that defer the
542+
// connection until first use surface their failure here.
543+
$mutex->isLocked('iptables-conf-probe');
544+
545+
return true;
546+
} catch (\Throwable $e) {
547+
SystemMessages::sysLogMsg(
548+
__METHOD__,
549+
'Mutex/Redis unavailable, falling back to direct firewall rule writes: '
550+
. $e->getMessage(),
551+
LOG_WARNING
552+
);
553+
return false;
477554
}
478555
}
479556

src/PBXCoreREST/Lib/Firewall/SaveRecordAction.php

Lines changed: 91 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
use MikoPBX\Core\Utilities\IpAddressHelper;
2727
use MikoPBX\PBXCoreREST\Lib\Common\AbstractSaveRecordAction;
2828
use MikoPBX\PBXCoreREST\Lib\PBXApiResult;
29-
use Phalcon\Di\Di;
3029

3130
/**
3231
* ✨ REFERENCE IMPLEMENTATION: Firewall Save Action (Security Critical)
@@ -219,100 +218,98 @@ public static function main(array $data): PBXApiResult
219218

220219
// ============================================================
221220
// PHASE 6: SAVE TO DATABASE
222-
// WHY: All-or-nothing transaction for NetworkFilter + FirewallRules
221+
// All writes run inside shared 'db-write' mutex + transaction
222+
// via BaseActionHelper::executeInTransaction(). This avoids
223+
// "database is locked" races with WorkerModelsEvents / IptablesConf
224+
// (see GitHub #1020, Sentry MIKOPBX-KMK/MIKOPBX-KMM).
223225
// ============================================================
224226

225-
$di = Di::getDefault();
226-
if ($di === null) {
227-
$res->messages['error'][] = 'DI container is not available';
228-
$res->httpCode = 500;
229-
return $res;
230-
}
231-
$db = $di->get('db');
232-
233227
try {
234-
$db->begin();
235-
236-
// Process network/subnet fields to calculate permit (CIDR notation)
237-
if (isset($sanitizedData['network']) && isset($sanitizedData['subnet'])) {
238-
$network = $sanitizedData['network'];
239-
$subnet = $sanitizedData['subnet'];
240-
241-
// Detect IP version to apply correct normalization
242-
$version = IpAddressHelper::getIpVersion($network);
243-
244-
if ($version === IpAddressHelper::IP_VERSION_4) {
245-
// IPv4: Normalize network address using Cidr calculator
246-
$calculator = new Cidr();
247-
$normalizedNetwork = $calculator->cidr2network($network, intval($subnet));
248-
$networkFilter->permit = $normalizedNetwork . '/' . $subnet;
249-
} else {
250-
// IPv6: Use address as-is (already validated in validateIpAndCidr)
251-
$networkFilter->permit = $network . '/' . $subnet;
228+
self::executeInTransaction(function () use (
229+
$networkFilter,
230+
$sanitizedData,
231+
$isNewRecord,
232+
$httpMethod,
233+
$res
234+
): void {
235+
// Process network/subnet fields to calculate permit (CIDR notation)
236+
if (isset($sanitizedData['network']) && isset($sanitizedData['subnet'])) {
237+
$network = $sanitizedData['network'];
238+
$subnet = $sanitizedData['subnet'];
239+
240+
// Detect IP version to apply correct normalization
241+
$version = IpAddressHelper::getIpVersion($network);
242+
243+
if ($version === IpAddressHelper::IP_VERSION_4) {
244+
// IPv4: Normalize network address using Cidr calculator
245+
$calculator = new Cidr();
246+
$normalizedNetwork = $calculator->cidr2network($network, intval($subnet));
247+
$networkFilter->permit = $normalizedNetwork . '/' . $subnet;
248+
} else {
249+
// IPv6: Use address as-is (already validated in validateIpAndCidr)
250+
$networkFilter->permit = $network . '/' . $subnet;
251+
}
252252
}
253-
}
254253

255-
// Convert boolean fields to '0'/'1' strings
256-
$sanitizedData = self::convertBooleanFields($sanitizedData, ['newer_block_ip', 'local_network']);
254+
// Convert boolean fields to '0'/'1' strings
255+
$convertedData = self::convertBooleanFields(
256+
$sanitizedData,
257+
['newer_block_ip', 'local_network']
258+
);
257259

258-
// Update NetworkFilter fields
259-
// For CREATE: All fields from $sanitizedData (with defaults)
260-
// For PATCH: Only provided fields (no defaults)
261-
if (isset($sanitizedData['deny'])) {
262-
$networkFilter->deny = $sanitizedData['deny'];
263-
}
264-
if (isset($sanitizedData['description'])) {
265-
$networkFilter->description = $sanitizedData['description'];
266-
}
267-
if (isset($sanitizedData['newer_block_ip'])) {
268-
$networkFilter->newer_block_ip = $sanitizedData['newer_block_ip'];
269-
}
270-
if (isset($sanitizedData['local_network'])) {
271-
$networkFilter->local_network = $sanitizedData['local_network'];
272-
}
260+
// Update NetworkFilter fields
261+
// For CREATE: All fields from $convertedData (with defaults)
262+
// For PATCH: Only provided fields (no defaults)
263+
if (isset($convertedData['deny'])) {
264+
$networkFilter->deny = $convertedData['deny'];
265+
}
266+
if (isset($convertedData['description'])) {
267+
$networkFilter->description = $convertedData['description'];
268+
}
269+
if (isset($convertedData['newer_block_ip'])) {
270+
$networkFilter->newer_block_ip = $convertedData['newer_block_ip'];
271+
}
272+
if (isset($convertedData['local_network'])) {
273+
$networkFilter->local_network = $convertedData['local_network'];
274+
}
273275

274-
if (!$networkFilter->save()) {
275-
$errors = $networkFilter->getMessages();
276-
foreach ($errors as $error) {
277-
$res->messages['error'][] = $error->getMessage();
276+
if (!$networkFilter->save()) {
277+
// Preserve per-field error messages as individual array
278+
// entries so the frontend can still display them one by
279+
// one. Throwing \DomainException signals a validation
280+
// failure (not an infrastructure error) — the outer catch
281+
// handles it without pushing another combined message.
282+
foreach ($networkFilter->getMessages() as $message) {
283+
$res->messages['error'][] = $message->getMessage();
284+
}
285+
throw new \DomainException('Network filter validation failed');
278286
}
279-
$db->rollback();
280-
$res->success = false;
281-
return $res;
282-
}
283287

284-
// Update FirewallRules if currentRules data is provided
285-
if (isset($sanitizedData['currentRules']) && is_array($sanitizedData['currentRules'])) {
286-
if ($isNewRecord) {
287-
// CREATE: Create new rules
288-
if (!self::createFirewallRules($networkFilter->id, $sanitizedData['currentRules'])) {
289-
$res->messages['error'][] = 'Failed to create firewall rules';
290-
$db->rollback();
291-
$res->success = false;
292-
return $res;
288+
// Update FirewallRules if currentRules data is provided
289+
if (isset($sanitizedData['currentRules']) && is_array($sanitizedData['currentRules'])) {
290+
if ($isNewRecord) {
291+
// CREATE: Create new rules
292+
if (!self::createFirewallRules($networkFilter->id, $sanitizedData['currentRules'])) {
293+
$res->messages['error'][] = 'Failed to create firewall rules';
294+
throw new \DomainException('createFirewallRules returned false');
295+
}
296+
} else {
297+
// UPDATE/PATCH: Update existing rules
298+
// WHY: PATCH = partial update (only provided fields), PUT = full update (all fields)
299+
$isPatch = ($httpMethod === 'PATCH');
300+
if (!self::updateFirewallRules($networkFilter->id, $sanitizedData['currentRules'], $isPatch)) {
301+
$res->messages['error'][] = 'Failed to update firewall rules';
302+
throw new \DomainException('updateFirewallRules returned false');
303+
}
293304
}
294-
} else {
295-
// UPDATE/PATCH: Update existing rules
296-
// WHY: PATCH = partial update (only provided fields), PUT = full update (all fields)
297-
$isPatch = ($httpMethod === 'PATCH');
298-
if (!self::updateFirewallRules($networkFilter->id, $sanitizedData['currentRules'], $isPatch)) {
299-
$res->messages['error'][] = 'Failed to update firewall rules';
300-
$db->rollback();
301-
$res->success = false;
302-
return $res;
305+
} elseif ($isNewRecord) {
306+
// CREATE without rules: Use defaults
307+
if (!self::createFirewallRules($networkFilter->id, [])) {
308+
$res->messages['error'][] = 'Failed to create default firewall rules';
309+
throw new \DomainException('createFirewallRules (defaults) returned false');
303310
}
304311
}
305-
} elseif ($isNewRecord) {
306-
// CREATE without rules: Use defaults
307-
if (!self::createFirewallRules($networkFilter->id, [])) {
308-
$res->messages['error'][] = 'Failed to create default firewall rules';
309-
$db->rollback();
310-
$res->success = false;
311-
return $res;
312-
}
313-
}
314-
315-
$db->commit();
312+
});
316313

317314
// ============================================================
318315
// PHASE 7: BUILD RESPONSE
@@ -324,10 +321,18 @@ public static function main(array $data): PBXApiResult
324321
$res->httpCode = $isNewRecord ? 201 : 200; // 201 Created, 200 OK
325322
$res->reload = "firewall/modify/{$networkFilter->id}";
326323

327-
self::logSuccessfulSave('Firewall rule', $networkFilter->description ?: $networkFilter->permit, (string)$networkFilter->id, __METHOD__);
328-
324+
self::logSuccessfulSave(
325+
'Firewall rule',
326+
$networkFilter->description ?: $networkFilter->permit,
327+
(string)$networkFilter->id,
328+
__METHOD__
329+
);
330+
} catch (\DomainException) {
331+
// Validation/business failure — messages already populated inside
332+
// the callback, rollback already performed by executeInTransaction.
333+
// Deliberately swallow the exception (no syslog, no extra message).
334+
$res->success = false;
329335
} catch (\Exception $e) {
330-
$db->rollback();
331336
return self::handleError($e, $res);
332337
}
333338

0 commit comments

Comments
 (0)