tsa: add get_config_v1 endpoint

This produces commented shaping.toml output corresponding to any config
overrides set by the automation.

For example, this non-sensical rule that applies to every domain:

```toml
[["default".automation]]
regex = "250 2\\.0\\.0 Ok"
action = {SetConfig={name="max_connection_rate", value="100/s"}}
trigger = {Threshold="2/hr"}
duration = "30 secs"
```

when triggered for messages sent to my own domain:

```console
  $ curl -s 'http://localhost:8008/get_config_v1'
  # Generated by tsa-daemon
  # Number of entries: 1

  ["wezfurlong.org"]

  ["wezfurlong.org".sources]

  ["wezfurlong.org".sources.unspecified]
  mx_rollup = false
  # reason: automation rule: 250 2\.0\.0 Ok
  # expires: 2023-08-03T01:47:37+00:00
  max_connection_rate = "100/s"
```
This commit is contained in:
Wez Furlong
2023-08-02 18:53:03 -07:00
parent d2cdd0ca0a
commit e53ccc491f
4 changed files with 127 additions and 16 deletions
Generated
+1
View File
@@ -5361,6 +5361,7 @@ dependencies = [
"tokio",
"tokio-rustls 0.23.4",
"toml",
"toml_edit",
"tracing",
"version-info",
]
@@ -132,6 +132,7 @@ impl HttpListenerParams {
}
}
#[derive(Debug)]
pub struct AppError(anyhow::Error);
// Tell axum how to convert `AppError` into a response.
+1
View File
@@ -32,5 +32,6 @@ tikv-jemallocator = "0.5"
tokio = {version="1.25", features=["full", "tracing"]}
tokio-rustls = "0.23"
toml = "0.7"
toml_edit = "0.19"
tracing = "0.1"
version-info = {path="../version-info"}
+124 -16
View File
@@ -1,5 +1,5 @@
use anyhow::{anyhow, Context};
use axum::routing::post;
use axum::routing::{get, post};
use axum::{Json, Router};
use dns_resolver::MailExchanger;
use kumo_api_types::shaping::{Action, EgressPathConfigValue, Rule, Shaping, Trigger};
@@ -10,9 +10,11 @@ use kumo_server_runtime::rt_spawn;
use once_cell::sync::Lazy;
use rfc5321::ForwardPath;
use serde::Serialize;
use serde_json::Value as JsonValue;
use sha2::{Digest, Sha256};
use sqlite::{Connection, ConnectionWithFullMutex};
use std::hash::Hash;
use toml_edit::{value, Value as TomlValue};
static HISTORY: Lazy<ConnectionWithFullMutex> = Lazy::new(|| open_history_db().unwrap());
@@ -24,7 +26,7 @@ fn open_history_db() -> anyhow::Result<ConnectionWithFullMutex> {
CREATE TABLE IF NOT EXISTS event_history (
rule_hash text,
record_hash text,
ts DATETIME,
ts int,
PRIMARY KEY (rule_hash, record_hash)
);
@@ -57,7 +59,9 @@ CREATE TABLE IF NOT EXISTS config (
}
pub fn make_router() -> Router {
Router::new().route("/publish_log_v1", post(publish_log_v1))
Router::new()
.route("/publish_log_v1", post(publish_log_v1))
.route("/get_config_v1", get(get_config_v1))
}
fn create_config(
@@ -79,18 +83,20 @@ fn create_config(
let expires = (record.timestamp + chrono::Duration::from_std(rule.duration)?).to_rfc3339();
upsert.bind(("hash", rule_hash))?;
upsert.bind(("site", record.site.as_str()))?;
upsert.bind(("domain", domain))?;
upsert.bind(("mx_rollup", if rule.was_rollup { 1 } else { 0 }))?;
upsert.bind(("source", source))?;
upsert.bind(("name", config.name.as_str()))?;
upsert.bind(("$hash", rule_hash))?;
upsert.bind(("$site", record.site.as_str()))?;
upsert.bind(("$domain", domain))?;
upsert.bind(("$mx_rollup", if rule.was_rollup { 1 } else { 0 }))?;
upsert.bind(("$source", source))?;
upsert.bind(("$name", config.name.as_str()))?;
let value = serde_json::to_string(&config.value)?;
upsert.bind(("value", value.as_str()))?;
upsert.bind(("$value", value.as_str()))?;
let reason = format!("automation rule: {}", rule.regex.to_string());
upsert.bind(("reason", reason.as_str()))?;
upsert.bind(("expires", expires.as_str()))?;
upsert.bind(("$reason", reason.as_str()))?;
upsert.bind(("$expires", expires.as_str()))?;
upsert.next()?;
Ok(())
}
@@ -107,13 +113,15 @@ fn create_suspension(rule_hash: &str, rule: &Rule, record: &JsonLogRecord) -> an
let expires = (record.timestamp + chrono::Duration::from_std(rule.duration)?).to_rfc3339();
upsert.bind(("hash", rule_hash))?;
upsert.bind(("site", record.site.as_str()))?;
upsert.bind(("$hash", rule_hash))?;
upsert.bind(("$site", record.site.as_str()))?;
let reason = format!("automation rule: {}", rule.regex.to_string());
upsert.bind(("reason", reason.as_str()))?;
upsert.bind(("expires", expires.as_str()))?;
upsert.bind(("$reason", reason.as_str()))?;
upsert.bind(("$expires", expires.as_str()))?;
upsert.next()?;
Ok(())
}
@@ -304,3 +312,103 @@ async fn publish_log_v1(
.await?;
rx.await?
}
fn json_to_toml_value(item_value: &JsonValue) -> anyhow::Result<TomlValue> {
use toml_edit::Formatted;
Ok(match item_value {
JsonValue::Bool(b) => TomlValue::Boolean(Formatted::new(*b)),
JsonValue::String(s) => TomlValue::String(Formatted::new(s.to_string())),
JsonValue::Array(a) => {
let mut res = toml_edit::Array::new();
for item in a {
res.push(json_to_toml_value(&item)?);
}
TomlValue::Array(res)
}
JsonValue::Object(o) => {
let mut tbl = toml_edit::InlineTable::new();
for (k, v) in o.iter() {
tbl.insert(k, json_to_toml_value(v)?);
}
TomlValue::InlineTable(tbl)
}
JsonValue::Number(n) => {
if let Some(i) = n.as_i64() {
TomlValue::Integer(Formatted::new(i))
} else if let Some(f) = n.as_f64() {
TomlValue::Float(Formatted::new(f))
} else {
anyhow::bail!("impossible number value {n:?}");
}
}
JsonValue::Null => anyhow::bail!("impossible value {item_value:?}"),
})
}
async fn do_get_config() -> anyhow::Result<String> {
use toml_edit::Item;
let mut doc = toml_edit::Document::new();
let mut stmt = HISTORY.prepare(
"SELECT * from config where
unixepoch(expires) - unixepoch() > 0
order by expires, domain, source, name",
)?;
let mut num_entries = 0;
while let Ok(sqlite::State::Row) = stmt.next() {
num_entries += 1;
let reason: String = stmt.read("reason")?;
let domain: String = stmt.read("domain")?;
let mx_rollup: i64 = stmt.read("mx_rollup")?;
let source: String = stmt.read("source")?;
let name: String = stmt.read("name")?;
let config_value: String = stmt.read("value")?;
let expires: String = stmt.read("expires")?;
let config_value = serde_json::from_str(&config_value)?;
let config_value = json_to_toml_value(&config_value)?;
let domain_entry = doc
.entry(&domain)
.or_insert_with(|| {
let tbl = toml_edit::Table::new();
Item::Table(tbl)
})
.as_table_mut()
.unwrap();
let sources = domain_entry
.entry("sources")
.or_insert_with(|| {
let tbl = toml_edit::Table::new();
Item::Table(tbl)
})
.as_table_mut()
.unwrap();
let source_entry = sources
.entry(&source)
.or_insert_with(|| {
let mut tbl = toml_edit::Table::new();
tbl["mx_rollup"] = value(mx_rollup != 0);
Item::Table(tbl)
})
.as_table_mut()
.unwrap();
let item = Item::Value(config_value);
source_entry.insert(&name, item);
if let Some(decor) = source_entry.key_decor_mut(&name) {
decor.set_prefix(format!("# reason: {reason}\n# expires: {expires}\n"));
}
}
Ok(format!(
"# Generated by tsa-daemon\n# Number of entries: {num_entries}\n\n{}",
doc.to_string()
))
}
async fn get_config_v1(_: TrustedIpRequired) -> Result<String, AppError> {
let result = do_get_config().await?;
Ok(result)
}