feat(signal): add admin endpoints, config, and server wiring

This commit is contained in:
Lewis
2026-03-22 07:14:20 +00:00
committed by Tangled
parent 9d31ee9ace
commit 7c55a5ceb9
11 changed files with 208 additions and 21 deletions
+3
View File
@@ -12,12 +12,15 @@ tranquil-db = { workspace = true }
tranquil-db-traits = { workspace = true }
tranquil-lexicon = { workspace = true, features = ["resolve"] }
tranquil-scopes = { workspace = true }
tranquil-signal = { workspace = true }
anyhow = { workspace = true }
axum = { workspace = true }
backon = { workspace = true }
base32 = { workspace = true }
base64 = { workspace = true }
image = { workspace = true }
qrcodegen = { workspace = true }
bcrypt = { workspace = true }
bs58 = { workspace = true }
bytes = { workspace = true }
+2
View File
@@ -2,6 +2,7 @@ pub mod account;
pub mod config;
pub mod invite;
pub mod server_stats;
pub mod signal;
pub mod status;
pub use account::{
@@ -13,4 +14,5 @@ pub use invite::{
disable_account_invites, disable_invite_codes, enable_account_invites, get_invite_codes,
};
pub use server_stats::get_server_stats;
pub use signal::{get_signal_status, link_signal_device, unlink_signal_device};
pub use status::{get_subject_status, update_subject_status};
+163
View File
@@ -0,0 +1,163 @@
use axum::{Json, extract::State};
use base64::{Engine, engine::general_purpose::STANDARD};
use image::{ImageBuffer, Luma};
use serde::Serialize;
use tranquil_pds::api::error::ApiError;
use tranquil_pds::auth::{Admin, Auth};
use tranquil_pds::state::AppState;
use tranquil_signal::PgSignalStore;
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SignalStatusOutput {
pub enabled: bool,
pub linked: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SignalLinkOutput {
pub qr_base64: String,
}
pub async fn get_signal_status(
State(state): State<AppState>,
_auth: Auth<Admin>,
) -> Result<Json<SignalStatusOutput>, ApiError> {
let enabled = tranquil_config::get().signal.enabled;
let linked = match &state.signal_sender {
Some(slot) => slot.is_linked().await,
None => false,
};
Ok(Json(SignalStatusOutput { enabled, linked }))
}
pub async fn link_signal_device(
State(state): State<AppState>,
_auth: Auth<Admin>,
) -> Result<Json<SignalLinkOutput>, ApiError> {
let slot = state
.signal_sender
.as_ref()
.ok_or_else(|| ApiError::InvalidRequest("Signal is not enabled".into()))?;
if slot.is_linked().await {
return Err(ApiError::InvalidRequest(
"Signal device already linked".into(),
));
}
let (generation, link_cancel) = slot.begin_link().await;
let device_name = tranquil_signal::DeviceName::new("tranquil-pds".to_string())
.map_err(|e| ApiError::InternalError(Some(format!("invalid device name: {e}"))))?;
let link_result = tranquil_signal::SignalClient::link_device(
&state.repos.pool,
device_name,
state.shutdown.clone(),
link_cancel,
slot.linking_flag(),
)
.await
.map_err(|e| ApiError::InternalError(Some(format!("Signal linking failed: {e}"))))?;
let qr_base64 = url_to_qr_png_base64(link_result.url.as_str())
.map_err(|e| ApiError::InternalError(Some(format!("QR generation failed: {e}"))))?;
let slot_for_task = slot.clone();
let shutdown = state.shutdown.clone();
tokio::spawn(async move {
let result = tokio::select! {
biased;
_ = shutdown.cancelled() => {
tracing::info!("Signal linking aborted due to server shutdown");
return;
}
r = link_result.completion => r,
};
match result {
Ok(Ok(client)) => {
if slot_for_task.complete_link(generation, client).await {
tracing::info!("Signal device linked successfully");
} else {
tracing::warn!(
"Signal link completed but generation mismatch or already linked; discarding"
);
}
}
Ok(Err(e)) => {
tracing::error!(error = %e, "Signal device linking failed");
}
Err(_) => {
tracing::error!("Signal linking task dropped without completing");
}
}
});
Ok(Json(SignalLinkOutput { qr_base64 }))
}
pub async fn unlink_signal_device(
State(state): State<AppState>,
_auth: Auth<Admin>,
) -> Result<Json<serde_json::Value>, ApiError> {
let slot = state
.signal_sender
.as_ref()
.ok_or_else(|| ApiError::InvalidRequest("Signal is not enabled".into()))?;
let store = PgSignalStore::new(state.repos.pool.clone());
store
.clear_all()
.await
.map_err(|e| ApiError::InternalError(Some(format!("Failed to clear signal data: {e}"))))?;
slot.unlink().await;
Ok(Json(serde_json::json!({})))
}
const QR_MODULE_SCALE: u32 = 8;
const QR_QUIET_ZONE_MODULES: u32 = 4;
fn url_to_qr_png_base64(url: &str) -> Result<String, String> {
let qr = qrcodegen::QrCode::encode_text(url, qrcodegen::QrCodeEcc::Medium)
.map_err(|e| format!("QR encode failed: {e:?}"))?;
let size = u32::try_from(qr.size()).map_err(|_| "QR size is negative".to_string())?;
let img_size = size
.checked_add(
QR_QUIET_ZONE_MODULES
.checked_mul(2)
.ok_or("border overflow")?,
)
.ok_or("image size overflow")?
.checked_mul(QR_MODULE_SCALE)
.ok_or("scaled size overflow")?;
let img: ImageBuffer<Luma<u8>, Vec<u8>> = ImageBuffer::from_fn(img_size, img_size, |x, y| {
let module_x = x / QR_MODULE_SCALE;
let module_y = y / QR_MODULE_SCALE;
match (
module_x.checked_sub(QR_QUIET_ZONE_MODULES),
module_y.checked_sub(QR_QUIET_ZONE_MODULES),
) {
(Some(mx), Some(my)) if mx < size && my < size => {
if qr.get_module(mx as i32, my as i32) {
Luma([0u8])
} else {
Luma([255u8])
}
}
_ => Luma([255u8]),
}
});
let mut png_bytes = Vec::new();
let mut cursor = std::io::Cursor::new(&mut png_bytes);
img.write_to(&mut cursor, image::ImageFormat::Png)
.map_err(|e| format!("PNG encode failed: {e}"))?;
Ok(STANDARD.encode(&png_bytes))
}
+6
View File
@@ -330,6 +330,12 @@ pub fn api_routes() -> axum::Router<AppState> {
get(admin::get_invite_codes),
)
.route("/_admin.getServerStats", get(admin::get_server_stats))
.route("/_admin.getSignalStatus", get(admin::get_signal_status))
.route("/_admin.linkSignalDevice", post(admin::link_signal_device))
.route(
"/_admin.unlinkSignalDevice",
post(admin::unlink_signal_device),
)
.route("/_server.getConfig", get(admin::get_server_config))
.route(
"/_admin.updateServerConfig",
+1 -1
View File
@@ -14,7 +14,7 @@ fn get_available_comms_channels() -> Vec<CommsChannel> {
if cfg.telegram.bot_token.is_some() {
channels.push(CommsChannel::Telegram);
}
if cfg.signal.sender_number.is_some() {
if cfg.signal.enabled {
channels.push(CommsChannel::Signal);
}
channels
+2 -7
View File
@@ -678,13 +678,8 @@ pub struct TelegramConfig {
#[derive(Debug, Config)]
pub struct SignalConfig {
/// Path to the `signal-cli` binary.
#[config(env = "SIGNAL_CLI_PATH", default = "/usr/local/bin/signal-cli")]
pub cli_path: String,
/// Sender phone number. When unset, Signal integration is disabled.
#[config(env = "SIGNAL_SENDER_NUMBER")]
pub sender_number: Option<String>,
#[config(env = "SIGNAL_ENABLED", default = false)]
pub enabled: bool,
}
#[derive(Debug, Config)]
+1
View File
@@ -15,6 +15,7 @@ tranquil-scopes = { workspace = true }
tranquil-auth = { workspace = true }
tranquil-oauth = { workspace = true }
tranquil-comms = { workspace = true }
tranquil-signal = { workspace = true }
tranquil-db = { workspace = true }
tranquil-db-traits = { workspace = true }
tranquil-lexicon = { workspace = true, features = ["resolve"] }
+7
View File
@@ -60,6 +60,7 @@ pub struct AppState {
pub cross_pds_oauth: Arc<CrossPdsOAuthClient>,
pub shutdown: CancellationToken,
pub bootstrap_invite_code: Option<String>,
pub signal_sender: Option<Arc<tranquil_signal::SignalSlot>>,
}
#[derive(Debug, Clone, Copy)]
@@ -310,6 +311,7 @@ impl AppState {
webauthn_config,
shutdown,
bootstrap_invite_code: None,
signal_sender: None,
}
}
@@ -328,6 +330,11 @@ impl AppState {
self
}
pub fn with_signal_sender(mut self, slot: Arc<tranquil_signal::SignalSlot>) -> Self {
self.signal_sender = Some(slot);
self
}
pub fn with_circuit_breakers(mut self, circuit_breakers: CircuitBreakers) -> Self {
self.circuit_breakers = Arc::new(circuit_breakers);
self
+1
View File
@@ -10,6 +10,7 @@ tranquil-sync = { workspace = true }
tranquil-api = { workspace = true }
tranquil-oauth-server = { workspace = true }
tranquil-config = { workspace = true }
tranquil-signal = { workspace = true }
axum = { workspace = true }
clap = { workspace = true }
+18 -3
View File
@@ -109,7 +109,22 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
spawn_signal_handler(shutdown.clone());
let state = AppState::new(shutdown.clone()).await?;
let mut state = AppState::new(shutdown.clone()).await?;
let signal_sender = if tranquil_config::get().signal.enabled {
let slot = Arc::new(tranquil_signal::SignalSlot::default());
state = state.with_signal_sender(slot.clone());
if let Some(client) =
tranquil_signal::SignalClient::from_pool(&state.repos.pool, shutdown.clone()).await
{
slot.set_client(client).await;
info!("Signal device already linked");
}
Some(SignalSender::new(slot))
} else {
None
};
tranquil_sync::listener::start_sequencer_listener(state.clone()).await;
let backfill_repo_repo = state.repo_repo.clone();
@@ -210,9 +225,9 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
comms_service = comms_service.register_sender(telegram_sender);
}
if let Some(signal_sender) = SignalSender::from_config(cfg) {
if let Some(sender) = signal_sender {
info!("Signal comms enabled");
comms_service = comms_service.register_sender(signal_sender);
comms_service = comms_service.register_sender(sender);
}
let comms_handle = tokio::spawn(comms_service.run(shutdown.clone()));
+4 -10
View File
@@ -320,17 +320,11 @@
#webhook_secret =
[signal]
# Path to the `signal-cli` binary.
# Protocol state is stored in postgres' signal_* tables.
# Link a device via the admin API before enabling.
#
# Can also be specified via environment variable `SIGNAL_CLI_PATH`.
#
# Default value: "/usr/local/bin/signal-cli"
#cli_path = "/usr/local/bin/signal-cli"
# Sender phone number. When unset, Signal integration is disabled.
#
# Can also be specified via environment variable `SIGNAL_SENDER_NUMBER`.
#sender_number =
# Can also be specified via environment variable `SIGNAL_ENABLED`.
#enabled = false
[notifications]
# Polling interval in milliseconds for the comms queue.