fix: tolerate lean UniFi payloads, redact DB log
Build and Push Docker Image / build (push) Successful in 6m24s
Build and Push Docker Image / build (push) Successful in 6m24s
A real Alarm Manager POST was rejected in production with `missing field "group"`. The payload model required nine fields the handler never reads (conditions, sources, name, eventPath, eventLocalLink, eventId, group, key, zones), so any absent one made serde reject the whole JSON and the plate was lost. Model only what is read — device, timestamp, value, thumbnail, snapshot — and document why the struct must stay lean. Regression test uses the payload shape observed on 2026-08-06. Stop logging the database URL in init_db: it printed the password in clear text, and startup already logs it via redacted_database_url(). The kauai .env pointed the database at 10.2.0.200, which is no host — the container crashlooped on first deploy with "No route to host". Use the postgres:5432 DNS alias on traefik_proxy, like the other stacks.
This commit is contained in:
@@ -36,7 +36,7 @@ $SUDO cp "$KIT/compose.yaml" "$DIR/compose.yaml"
|
||||
JWT=$(openssl rand -hex 32)
|
||||
umask 077
|
||||
$SUDO tee "$DIR/.env" >/dev/null <<EOF
|
||||
DATABASE_URL=postgresql://gate_watch:${PASS}@10.2.0.200:5432/gate_watch
|
||||
DATABASE_URL=postgresql://gate_watch:${PASS}@postgres:5432/gate_watch
|
||||
TELEGRAM_TOKEN=${TOKEN}
|
||||
JWT_SECRET=${JWT}
|
||||
PASSAGE_DEBOUNCE_SECONDS=10
|
||||
|
||||
@@ -221,4 +221,26 @@ mod tests {
|
||||
let parsed: PlateUnifi = serde_json::from_str(&payload).expect("payload deve parsear");
|
||||
assert!(unifi_alarm_image(&parsed.alarm).is_none());
|
||||
}
|
||||
|
||||
/// O Protect manda variantes enxutas do payload: em 2026-08-06, em produção,
|
||||
/// um POST real foi recusado com `missing field "group"`. Só device/timestamp/
|
||||
/// value são lidos, então nenhum outro campo pode ser obrigatório — senão o
|
||||
/// serde rejeita o JSON inteiro e a placa se perde.
|
||||
#[test]
|
||||
fn unifi_payload_sem_campos_ignorados_parseia() {
|
||||
let payload = r#"{
|
||||
"alarm": {
|
||||
"triggers": [{
|
||||
"device": "1C6A1B8334CA",
|
||||
"timestamp": 1786028513717,
|
||||
"value": "KDO4G34"
|
||||
}]
|
||||
},
|
||||
"timestamp": 1786028513717
|
||||
}"#;
|
||||
let parsed: PlateUnifi = serde_json::from_str(payload).expect("payload enxuto deve parsear");
|
||||
assert_eq!(parsed.alarm.triggers[0].value, "KDO4G34");
|
||||
assert_eq!(parsed.alarm.triggers[0].device, "1C6A1B8334CA");
|
||||
assert_eq!(parsed.alarm.triggers[0].timestamp, 1786028513717);
|
||||
}
|
||||
}
|
||||
|
||||
+13
-45
@@ -1,65 +1,33 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Payload do Alarm Manager do UniFi Protect (`POST /api/unifi`).
|
||||
///
|
||||
/// Modelado com SÓ os campos que o handler lê. O Protect manda vários campos a
|
||||
/// mais (`conditions`, `sources`, `name`, `eventPath`, `eventId`, `group`,
|
||||
/// `key`, `zones`…) e a lista varia conforme a regra e a versão do firmware —
|
||||
/// exigir qualquer um deles faz o serde recusar o JSON inteiro e a placa se
|
||||
/// perde. Não "complete" este modelo: campo novo entra só quando for lido, e
|
||||
/// como `Option`.
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct PlateUnifi {
|
||||
pub alarm: Alarm,
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Alarm {
|
||||
pub conditions: Vec<Condition>,
|
||||
#[serde(rename = "eventLocalLink")]
|
||||
pub event_local_link: String,
|
||||
#[serde(rename = "eventPath")]
|
||||
pub event_path: String,
|
||||
pub name: String,
|
||||
pub sources: Vec<Source>,
|
||||
/// Foto do evento em base64 (data-URI ou base64 puro).
|
||||
pub thumbnail: Option<String>,
|
||||
#[serde(rename = "snapshot")]
|
||||
/// Alternativa ao `thumbnail`, conforme a configuração da regra.
|
||||
pub snapshot: Option<String>,
|
||||
pub triggers: Vec<Trigger>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Source {
|
||||
pub device: String,
|
||||
#[serde(rename = "type")]
|
||||
pub source_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Condition {
|
||||
pub condition: ConditionDetail,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ConditionDetail {
|
||||
pub source: String,
|
||||
#[serde(rename = "type")]
|
||||
pub condition_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Trigger {
|
||||
/// Id do dispositivo no Protect (vai pra `passages.camera_id`).
|
||||
pub device: String,
|
||||
#[serde(rename = "eventId")]
|
||||
pub event_id: String,
|
||||
pub group: TriggerGroup,
|
||||
pub key: String,
|
||||
/// Epoch em milissegundos.
|
||||
pub timestamp: i64,
|
||||
/// A placa lida.
|
||||
pub value: String,
|
||||
pub zones: Zones,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct TriggerGroup {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Zones {
|
||||
pub line: Vec<i32>,
|
||||
pub loiter: Vec<i32>,
|
||||
pub zone: Vec<i32>,
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ pub type DbPool = PgPool;
|
||||
|
||||
/// Initializes the database connection pool
|
||||
pub async fn init_db(database_url: &str) -> Result<DbPool, sqlx::Error> {
|
||||
tracing::info!("Connecting to database: {}", database_url);
|
||||
|
||||
// Não logar a URL aqui: ela carrega a senha em texto claro. O startup já a
|
||||
// registra mascarada via `Config::redacted_database_url()`.
|
||||
let pool = PgPoolOptions::new()
|
||||
.max_connections(20)
|
||||
.min_connections(5)
|
||||
|
||||
Reference in New Issue
Block a user