stasis_broadcast: Add optional ARI broadcast with first-claim-wins

Adds two optional modules:
res_stasis_broadcast.so: Infrastructure for broadcasting a single incoming
channel to multiple ARI applications with atomic first-claim-wins semantics.

app_stasis_broadcast.so: Provides the StasisBroadcast() dialplan application
which invokes the broadcast infrastructure.

Both modules are self-contained; if neither is loaded there is zero runtime
impact. Loading them does not alter existing Stasis or ARI behavior unless
explicitly used.

Key Features (only active when modules are loaded):
Fisher-Yates shuffled broadcast dispatch for fair claim races
Atomic claim operations using mutex + condition variable signaling
Configurable broadcast timeouts
Safe regex application filtering with validation to mitigate ReDoS risk
Thread-safe channel variable snapshotting (channel locked during reads)
Late-claim safety: broadcast context kept alive until after the Stasis
session ends so concurrent claimants always receive 409 Conflict rather
than 404 Not Found
Memory safety via RAII_VAR, ast_json_ref/unref, and ao2 reference counting

Components Added:
res/res_stasis_broadcast.c: Core broadcast + claim logic
apps/app_stasis_broadcast.c: StasisBroadcast() dialplan application
include/asterisk/stasis_app_broadcast.h: Public API header
res/ari/resource_events.c: Integrates POST /ari/events/claim endpoint
rest-api/api-docs/events.json: New CallBroadcast and CallClaimed events

Implementation Notes:
Broadcast contexts reside in an ao2 hash container keyed by channel id. Each
context holds atomic claim state, winner application name, timeout metadata,
and a condition variable for waiters. Broadcast contexts are kept alive until
after stasis_app_exec() returns so that concurrent claimants racing against
the timeout always receive 409 Conflict. Broadcast dispatch calls
stasis_app_send() directly for each matching application in shuffled order.
Regex filters are validated with bounded length, group depth, quantified
group count, and alternation limits to reduce pathological backtracking.
Timeout calculation uses timespec arithmetic with overflow-safe millisecond
remainder handling. Event JSON follows existing Stasis/ARI conventions;
references are managed correctly to avoid leaks or double frees.

Optional Nature / Impact:
No changes to existing APIs, events, or applications when absent.
Clean fallback: systems ignoring the modules behave identically to prior
versions.

Development was assisted by Claude (Anthropic). All generated code has been
reviewed, tested, and is understood by the author.

UserNote: New optional modules res_stasis_broadcast.so and
app_stasis_broadcast.so enable broadcasting an incoming channel to multiple
ARI applications. The first application to successfully claim (via
POST /ari/events/claim) wins channel control. StasisBroadcast() dialplan
application initiates broadcasts. CallBroadcast and CallClaimed events notify
applications. When modules are not loaded, behavior is unchanged.

DeveloperNote: New public APIs in stasis_app_broadcast.h:
stasis_app_broadcast_channel(), stasis_app_claim_channel(),
stasis_app_broadcast_winner(), and stasis_app_broadcast_wait(). New ARI event
types (CallBroadcast, CallClaimed) added to events.json. All code is isolated;
no existing ABI modified.
This commit is contained in:
Daniel Donoghue
2026-02-25 15:07:48 +01:00
parent 3da083d71a
commit 61ba588d58
10 changed files with 1893 additions and 3 deletions

View File

@@ -0,0 +1,131 @@
/*
* Asterisk -- An open source telephony toolkit.
*
* Copyright (C) 2026, Aurora Innovation AB
*
* Daniel Donoghue <daniel.donoghue@aurorainnovation.com>
*
* See http://www.asterisk.org for more information about
* the Asterisk project. Please do not directly contact
* any of the maintainers of this project for assistance;
* the project provides a web site, mailing lists and IRC
* channels for your use.
*
* This program is free software, distributed under the terms of
* the GNU General Public License Version 2. See the LICENSE file
* at the top of the source tree.
*/
#ifndef _ASTERISK_STASIS_APP_BROADCAST_H
#define _ASTERISK_STASIS_APP_BROADCAST_H
/*! \file
*
* \brief Stasis Application Broadcast API
*
* \author Daniel Donoghue <daniel.donoghue@aurorainnovation.com>
*
* This module provides the infrastructure for broadcasting incoming channels
* to multiple ARI applications and handling first-claim winner logic.
*/
#include "asterisk/channel.h"
#include "asterisk/optional_api.h"
/*! \brief Suppress CallClaimed event for this broadcast */
#define STASIS_BROADCAST_FLAG_SUPPRESS_CLAIMED (1 << 0)
/*!
* \brief Start a broadcast for a channel
*
* \since 20
*
* Broadcasts a channel to all ARI applications (or filtered applications)
* allowing them to claim the channel. Only the first claim will succeed.
*
* When a channel is claimed, a CallClaimed event is sent only to applications
* that matched the \a app_filter (or all apps if no filter was set). This can
* be suppressed entirely with #STASIS_BROADCAST_FLAG_SUPPRESS_CLAIMED.
*
* \param chan The channel to broadcast
* \param timeout_ms Timeout in milliseconds to wait for a claim
* \param app_filter Optional regex filter for application names (NULL for all)
* \param flags Combination of STASIS_BROADCAST_FLAG_* values
*
* \retval 0 on success
* \retval -1 on error
* \retval AST_OPTIONAL_API_UNAVAILABLE if res_stasis_broadcast is not loaded
*/
AST_OPTIONAL_API(int, stasis_app_broadcast_channel,
(struct ast_channel *chan, int timeout_ms, const char *app_filter,
unsigned int flags),
{ return AST_OPTIONAL_API_UNAVAILABLE; });
/*!
* \brief Attempt to claim a broadcast channel
*
* \since 20
*
* Atomically attempts to claim a channel that is in broadcast state.
* Only the first claim for a given channel will succeed.
*
* \param channel_id The unique ID of the channel
* \param app_name The name of the application claiming the channel
*
* \retval 0 if claim successful
* \retval -1 if channel not found
* \retval -2 if already claimed by another application
* \retval AST_OPTIONAL_API_UNAVAILABLE if res_stasis_broadcast is not loaded
*/
AST_OPTIONAL_API(int, stasis_app_claim_channel,
(const char *channel_id, const char *app_name),
{ return AST_OPTIONAL_API_UNAVAILABLE; });
/*!
* \brief Get the winner app name for a broadcast channel
*
* \since 20
*
* \param channel_id The unique ID of the channel
*
* \return A copy of the winner app name (caller must free with ast_free),
* or NULL if not claimed or not found
* \retval NULL if res_stasis_broadcast is not loaded
*/
AST_OPTIONAL_API(char *, stasis_app_broadcast_winner,
(const char *channel_id),
{ return NULL; });
/*!
* \brief Wait for a broadcast channel to be claimed
*
* \since 20
*
* Blocks until the channel is claimed or the timeout expires.
*
* \param chan The channel
* \param timeout_ms Maximum time to wait in milliseconds
*
* \retval 0 if claimed within timeout
* \retval -1 if timeout expired or error
* \retval AST_OPTIONAL_API_UNAVAILABLE if res_stasis_broadcast is not loaded
*/
AST_OPTIONAL_API(int, stasis_app_broadcast_wait,
(struct ast_channel *chan, int timeout_ms),
{ return AST_OPTIONAL_API_UNAVAILABLE; });
/*!
* \brief Clean up broadcast context for a channel
*
* \since 20
*
* Removes the broadcast context when the channel is done or leaving the
* broadcast state.
*
* \param channel_id The unique ID of the channel
*/
AST_OPTIONAL_API(void, stasis_app_broadcast_cleanup,
(const char *channel_id),
{ return; });
#endif /* _ASTERISK_STASIS_APP_BROADCAST_H */