use std::num::NonZeroU32;
use std::path::PathBuf;
use anyhow::Context as _;
use serde::Deserialize;
#[derive(Debug, Clone)]
pub struct ActivateConfig {
pub nginx_binary: String,
pub reload_command: Option<String>,
pub listen: String,
pub real_ip_from: Vec<String>,
pub keep_generations: usize,
pub hooks: Vec<Hook>,
}
/// hooks run after each activation, and a new activation supersedes the hook's own running one
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Hook {
pub name: String,
pub command: PathBuf,
#[serde(default)]
pub args: Vec<String>,
}
impl Hook {
pub(crate) fn validate(hooks: Vec<Self>) -> anyhow::Result<Vec<Self>> {
let mut names = std::collections::BTreeSet::new();
for hook in &hooks {
anyhow::ensure!(
!hook.name.is_empty()
&& hook
.name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_')),
"hooks name {:?} is not a plain name",
hook.name
);
anyhow::ensure!(
names.insert(hook.name.as_str()),
"hooks names {:?} more than once",
hook.name
);
}
Ok(hooks)
}
}
impl Default for ActivateConfig {
fn default() -> Self {
Self {
nginx_binary: "nginx".to_string(),
reload_command: None,
listen: "8080".to_string(),
real_ip_from: Vec::new(),
keep_generations: DEFAULT_KEEP_GENERATIONS,
hooks: Vec::new(),
}
}
}
/// `g` is rejected because `limit_rate` doesn't read it.
/// nginx reads an unparsable size through a variable as no limit. only plain sizes get in.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ByteSize(String);
impl ByteSize {
pub(crate) fn parse(text: &str, field: &str) -> anyhow::Result<Option<Self>> {
let bad = || anyhow::anyhow!("{field} {text:?} is not a size like \"2m\" or \"off\"");
let (digits, scale) = match text.strip_suffix(['k', 'K']) {
Some(digits) => (digits, 1024),
None => match text.strip_suffix(['m', 'M']) {
Some(digits) => (digits, 1024 * 1024),
None => (text, 1),
},
};
anyhow::ensure!(
!digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()),
"{}",
bad()
);
let bytes: i64 = digits.parse().map_err(|_| bad())?;
anyhow::ensure!(
bytes.checked_mul(scale).is_some(),
"{field} {text:?} is more bytes than nginx counts"
);
Ok((bytes > 0).then(|| Self(text.to_string())))
}
}
impl std::fmt::Display for ByteSize {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
/// nginx refuses a `limit_conn` count above 65535 outright.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Connections(NonZeroU32);
const MOST_CONNECTIONS: u32 = 65535;
impl Connections {
pub(crate) fn parse(count: u32, field: &str) -> anyhow::Result<Option<Self>> {
anyhow::ensure!(
count <= MOST_CONNECTIONS,
"{field} {count} is more connections than nginx counts ({MOST_CONNECTIONS})"
);
Ok(NonZeroU32::new(count).map(Self))
}
}
impl std::fmt::Display for Connections {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt(f)
}
}
/// nginx scales the count by 1000 in an unsigned int, which is why the most is 1_000_000.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestRate(String);
const MOST_REQUESTS: u32 = 1_000_000;
impl RequestRate {
pub(crate) fn parse(text: &str, field: &str) -> anyhow::Result<Self> {
let bad = || anyhow::anyhow!("{field} {text:?} is not a rate like \"20r/s\" or \"off\"");
let (requests, unit) = text.split_once("r/").ok_or_else(bad)?;
anyhow::ensure!(matches!(unit, "s" | "m"), "{}", bad());
anyhow::ensure!(
!requests.is_empty() && requests.bytes().all(|byte| byte.is_ascii_digit()),
"{}",
bad()
);
let requests: u32 = requests.parse().map_err(|_| bad())?;
anyhow::ensure!(
(1..=MOST_REQUESTS).contains(&requests),
"{field} {text:?} is not between 1r and {MOST_REQUESTS}r"
);
Ok(Self(text.to_string()))
}
pub(crate) fn over(&self, secs: u32) -> u32 {
let (requests, unit) = self.0.split_once("r/").expect("a parsed rate");
let requests: u32 = requests.parse().expect("a parsed rate");
match unit {
"s" => requests * secs,
_ => requests * secs / 60,
}
}
}
impl std::fmt::Display for RequestRate {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IpPrefix(String);
impl IpPrefix {
pub(crate) fn parse(entry: &str, field: &str) -> anyhow::Result<Self> {
let bad = || anyhow::anyhow!("{field} entry {entry:?} is not an address or prefix");
let (address, prefix) = match entry.split_once('/') {
Some((address, prefix)) => (address, Some(prefix)),
None => (entry, None),
};
let address: std::net::IpAddr = address.parse().map_err(|_| bad())?;
if let Some(prefix) = prefix {
let bits: u8 = prefix.parse().map_err(|_| bad())?;
anyhow::ensure!(
bits <= if address.is_ipv4() { 32 } else { 128 },
"{field} entry {entry:?} has more prefix bits than its address family"
);
}
Ok(Self(entry.to_string()))
}
}
impl std::fmt::Display for IpPrefix {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
/// `"0"` is how nginx spells no limit, which is what an unset trusted rate sends.
#[derive(Debug, Clone, Default)]
pub struct ServeConfig {
pub trusted: Vec<IpPrefix>,
pub trusted_rate: Option<ByteSize>,
pub limits: ServeLimits,
}
impl ServeConfig {
pub fn trusted_rate(&self) -> String {
self.trusted_rate
.as_ref()
.map_or_else(|| "0".to_string(), ByteSize::to_string)
}
}
#[derive(Debug, Clone)]
pub struct ServeLimits {
pub pack_rate: Option<ByteSize>,
pub pack_rate_after: Option<ByteSize>,
pub packs_in_flight: Option<Connections>,
pub reads: Option<RequestRate>,
pub reads_burst: u32,
pub read_connections: Option<Connections>,
pub blob_connections: Option<Connections>,
pub blob_rate: Option<ByteSize>,
pub blob_rate_after: Option<ByteSize>,
/// each one holds an fcgiwrap worker while the cgi inflates it.
pub inflate_rate_all: Option<RequestRate>,
}
impl Default for ServeLimits {
fn default() -> Self {
let size = |text: &str| ByteSize(text.to_string());
let count = |count: u32| NonZeroU32::new(count).map(Connections);
Self {
pack_rate: Some(size("2m")),
pack_rate_after: Some(size("32m")),
packs_in_flight: count(16),
reads: Some(RequestRate::parse("20r/s", "default").unwrap()),
reads_burst: 50,
read_connections: count(32),
blob_connections: count(2),
blob_rate: Some(size("10m")),
blob_rate_after: Some(size("8m")),
inflate_rate_all: Some(RequestRate::parse("50r/s", "default").unwrap()),
}
}
}
/// templates expanded per generation, see `index_page::Vars`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexConfig {
pub title: String,
/// `[label](https://...)` becomes a link
pub subtitle: String,
pub description: String,
pub og_title: String,
/// `*text*` is drawn in the accent colour
pub og_subtitle: String,
/// up to `MOST_OG_DESCRIPTION_LINES` lines, `*text*` in the accent colour
pub og_description: String,
/// derived from the og_* lines when unset
pub og_alt: Option<String>,
/// where owners and repos link to, as `<links>/<owner>/<name>`
pub links: String,
/// web fonts for the page; without them it draws in the system's monospace
pub fonts: Option<IndexFonts>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexFonts {
/// the page's text; without it the page stays monospace, in `mono`
pub sans: Option<String>,
/// dids, and everything when there's no `sans`
pub mono: Option<String>,
pub faces: Vec<FontFace>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FontFace {
pub family: String,
#[serde(default = "FontFace::regular")]
pub weight: u16,
#[serde(default)]
pub italic: bool,
/// a same-origin path such as `/fonts/outfit-400.woff2`, or an http(s) url
pub url: String,
}
impl FontFace {
fn regular() -> u16 {
400
}
}
pub const MOST_OG_DESCRIPTION_LINES: usize = 3;
impl Default for IndexConfig {
fn default() -> Self {
Self {
title: "{hostname}".to_string(),
subtitle: "a [static knot](https://tangled.org/ptr.pet/yumak) for [tangled](https://tangled.org)"
.to_string(),
description: "a knot for tangled".to_string(),
og_title: "{hostname}".to_string(),
og_subtitle: "a static knot for *tangled*".to_string(),
og_description: "*{repos}* repos across *{users}* owners\nserved straight from nginx *{kaomoji}*".to_string(),
og_alt: None,
links: "https://tangled.org".to_string(),
fonts: None,
}
}
}
impl IndexConfig {
pub(crate) fn from_raw(raw: RawIndex) -> anyhow::Result<Self> {
let defaults = Self::default();
let index = Self {
title: raw.title.unwrap_or(defaults.title),
subtitle: raw.subtitle.unwrap_or(defaults.subtitle),
description: raw.description.unwrap_or(defaults.description),
og_title: raw.og_title.unwrap_or(defaults.og_title),
og_subtitle: raw.og_subtitle.unwrap_or(defaults.og_subtitle),
og_description: raw.og_description.unwrap_or(defaults.og_description),
og_alt: raw.og_alt,
links: raw.links.unwrap_or(defaults.links),
fonts: raw.fonts.map(IndexFonts::from_raw).transpose()?,
};
anyhow::ensure!(
index.links.starts_with("https://") || index.links.starts_with("http://"),
"index.links {:?} is not an http(s) url",
index.links
);
anyhow::ensure!(
index.og_description.lines().count() <= MOST_OG_DESCRIPTION_LINES,
"index.og_description has more than {MOST_OG_DESCRIPTION_LINES} lines"
);
Ok(index)
}
}
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub(crate) struct RawIndex {
#[serde(default)]
pub(crate) title: Option<String>,
#[serde(default)]
pub(crate) subtitle: Option<String>,
#[serde(default)]
pub(crate) description: Option<String>,
#[serde(default)]
pub(crate) og_title: Option<String>,
#[serde(default)]
pub(crate) og_subtitle: Option<String>,
#[serde(default)]
pub(crate) og_description: Option<String>,
#[serde(default)]
pub(crate) og_alt: Option<String>,
#[serde(default)]
pub(crate) links: Option<String>,
#[serde(default)]
pub(crate) fonts: Option<RawFonts>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RawFonts {
#[serde(default)]
pub(crate) sans: Option<String>,
#[serde(default)]
pub(crate) mono: Option<String>,
#[serde(default)]
pub(crate) faces: Vec<FontFace>,
}
impl IndexFonts {
fn from_raw(raw: RawFonts) -> anyhow::Result<Self> {
// names and urls go into the page's css as quoted strings
let plain = |text: &str| {
!text.is_empty()
&& !text
.chars()
.any(|c| c.is_control() || "\"'\\<>(){};".contains(c))
};
let families: Vec<&String> = raw.sans.iter().chain(&raw.mono).collect();
anyhow::ensure!(
!families.is_empty(),
"index.fonts names neither sans nor mono"
);
for family in &families {
anyhow::ensure!(
plain(family),
"index.fonts family {family:?} is not a plain name"
);
anyhow::ensure!(
raw.faces.iter().any(|face| &&face.family == family),
"index.fonts has no face for {family:?}"
);
}
for face in &raw.faces {
anyhow::ensure!(
families.contains(&&face.family),
"index.fonts face {:?} is neither the sans nor the mono family",
face.family
);
anyhow::ensure!(
(1..=1000).contains(&face.weight),
"index.fonts face weight {} is outside 1..=1000",
face.weight
);
let path = face.url.starts_with('/') && !face.url.starts_with("//");
let http = face.url.starts_with("https://") || face.url.starts_with("http://");
anyhow::ensure!(
(path || http) && plain(&face.url) && !face.url.contains(char::is_whitespace),
"index.fonts face url {:?} is not a same-origin path or an http(s) url",
face.url
);
}
Ok(Self {
sans: raw.sans,
mono: raw.mono,
faces: raw.faces,
})
}
}
#[derive(Debug, Clone, Default)]
pub struct WritesConfig {
pub enabled: bool,
pub fastcgi_socket: Option<String>,
pub cgi_binary: PathBuf,
}
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub(crate) struct RawWrites {
#[serde(default)]
pub(crate) enabled: bool,
#[serde(default)]
pub(crate) fastcgi_socket: Option<String>,
#[serde(default)]
pub(crate) cgi_binary: Option<PathBuf>,
}
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub(crate) struct RawServe {
#[serde(default)]
pub(crate) trusted: Vec<String>,
#[serde(default)]
pub(crate) trusted_rate: Option<toml::Value>,
#[serde(default)]
pub(crate) limits: RawLimits,
}
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub(crate) struct RawLimits {
#[serde(default)]
pub(crate) pack_rate: Option<toml::Value>,
#[serde(default)]
pub(crate) pack_rate_after: Option<toml::Value>,
#[serde(default)]
pub(crate) packs_in_flight: Option<toml::Value>,
#[serde(default)]
pub(crate) reads: Option<toml::Value>,
#[serde(default)]
pub(crate) reads_burst: Option<toml::Value>,
#[serde(default)]
pub(crate) read_connections: Option<toml::Value>,
#[serde(default)]
pub(crate) blob_connections: Option<toml::Value>,
#[serde(default)]
pub(crate) blob_rate: Option<toml::Value>,
#[serde(default)]
pub(crate) blob_rate_after: Option<toml::Value>,
#[serde(default)]
pub(crate) inflate_rate_all: Option<toml::Value>,
}
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub(crate) struct RawCrawl {
#[serde(default)]
pub(crate) urls: Vec<String>,
}
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub(crate) struct RawFirehose {
#[serde(default)]
pub(crate) socket: Option<PathBuf>,
}
#[derive(Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub(crate) struct RawActivate {
#[serde(default)]
pub(crate) nginx_binary: Option<String>,
#[serde(default)]
pub(crate) reload_command: Option<String>,
#[serde(default)]
pub(crate) listen: Option<String>,
#[serde(default)]
pub(crate) real_ip_from: Vec<String>,
#[serde(default)]
pub(crate) keep_generations: Option<usize>,
}
pub(super) const DEFAULT_KEEP_GENERATIONS: usize = 2;
pub(crate) fn listens_on_loopback(listen: &str) -> bool {
let host = listen.rsplit_once(':').map_or(listen, |(host, _)| host);
let host = host.trim_start_matches('[').trim_end_matches(']');
host == "localhost"
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|address| address.is_loopback())
}
pub(crate) fn count(
value: Option<toml::Value>,
default: Option<Connections>,
field: &str,
) -> anyhow::Result<Option<Connections>> {
let Some(value) = value else {
return Ok(default);
};
if value.as_str() == Some("off") {
return Ok(None);
}
let count = value
.as_integer()
.and_then(|count| u32::try_from(count).ok())
.with_context(|| format!("{field} {value} is not a count or \"off\""))?;
Connections::parse(count, field)
}
pub(crate) fn size(
value: Option<toml::Value>,
default: Option<ByteSize>,
field: &str,
) -> anyhow::Result<Option<ByteSize>> {
let Some(value) = value else {
return Ok(default);
};
if value.as_str() == Some("off") {
return Ok(None);
}
let text = match &value {
toml::Value::Integer(bytes) => bytes.to_string(),
toml::Value::String(text) => text.clone(),
_ => anyhow::bail!("{field} {value} is not a size like \"2m\" or \"off\""),
};
ByteSize::parse(&text, field)
}
pub(crate) fn request_rate(
value: Option<toml::Value>,
default: Option<RequestRate>,
field: &str,
) -> anyhow::Result<Option<RequestRate>> {
let Some(value) = value else {
return Ok(default);
};
if value.as_str() == Some("off") {
return Ok(None);
}
let text = value
.as_str()
.with_context(|| format!("{field} {value} is not a rate like \"20r/s\" or \"off\""))?;
RequestRate::parse(text, field).map(Some)
}
/// `off` means no burst, and a zero gets no `burst=` from nginx.
pub(crate) fn burst(value: Option<toml::Value>, default: u32, field: &str) -> anyhow::Result<u32> {
let Some(value) = value else {
return Ok(default);
};
if value.as_str() == Some("off") {
return Ok(0);
}
value
.as_integer()
.and_then(|burst| u32::try_from(burst).ok())
.with_context(|| format!("{field} {value} is not a count or \"off\""))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RawConfig {
pub(crate) knot: RawKnot,
pub(crate) repo: RawRepo,
pub(crate) secrets: RawSecrets,
#[serde(default)]
pub(crate) atproto: RawAtproto,
pub(crate) output: RawOutput,
#[serde(default)]
pub(crate) activate: RawActivate,
#[serde(default)]
pub(crate) crawl: RawCrawl,
#[serde(default)]
pub(crate) writes: RawWrites,
#[serde(default)]
pub(crate) serve: RawServe,
#[serde(default)]
pub(crate) firehose: RawFirehose,
#[serde(default)]
pub(crate) index: RawIndex,
#[serde(default)]
pub(crate) hooks: Vec<Hook>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RawKnot {
pub(crate) hostname: String,
pub(crate) owner: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RawRepo {
pub(crate) scan_path: PathBuf,
#[serde(default)]
pub(crate) default_branch: Option<String>,
#[serde(default)]
pub(crate) object_format: Option<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RawSecrets {
pub(crate) sealed_key_file: PathBuf,
#[serde(default = "default_master_key_env")]
pub(crate) master_key_env: String,
}
fn default_master_key_env() -> String {
"KNOT_MASTER_KEY".to_string()
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RawAtproto {
#[serde(default = "default_plc_directory")]
pub(crate) plc_directory: String,
#[serde(default)]
pub(crate) allow_private_addresses: bool,
}
impl Default for RawAtproto {
fn default() -> Self {
Self {
plc_directory: default_plc_directory(),
allow_private_addresses: false,
}
}
}
fn default_plc_directory() -> String {
"https://plc.directory".to_string()
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct RawOutput {
pub(crate) root: PathBuf,
}