use std::path::{Path, PathBuf};
use anyhow::Context as _;
use knot_git::Layout;
use knot_types::{AccountDid, BranchName, KnotHostname, ObjectFormat};
#[cfg(test)]
mod tests;
mod types;
pub use types::*;
pub struct ValidatedConfig {
pub config_path: PathBuf,
pub hostname: KnotHostname,
pub knot_did: knot_types::KnotId,
pub owner: AccountDid,
pub scan_path: PathBuf,
pub default_branch: BranchName,
pub object_format: ObjectFormat,
pub sealed_key_file: PathBuf,
pub master_key_env: String,
pub plc_directory: url::Url,
pub output_root: PathBuf,
pub crawlers: Vec<url::Url>,
pub allow_private_addresses: bool,
pub activate: ActivateConfig,
pub writes: WritesConfig,
pub serve: ServeConfig,
pub firehose_socket: Option<PathBuf>,
pub index: IndexConfig,
}
impl ValidatedConfig {
pub fn atproto(
&self,
transport: knot_runtime::ReqwestHttp,
) -> anyhow::Result<knot_atproto::Atproto<knot_runtime::ReqwestHttp, knot_runtime::SystemClock>>
{
Ok(knot_atproto::Atproto::new(
transport,
knot_runtime::SystemClock,
self.knot_did.clone(),
knot_atproto::PlcDirectory::new(self.plc_directory.clone())?,
))
}
pub fn http_limits(&self) -> knot_runtime::HttpLimits {
knot_runtime::HttpLimits {
block_private_addresses: !self.allow_private_addresses,
..knot_runtime::HttpLimits::default()
}
}
/// every input that changes the output must be hashed here. planning misses anything left out.
pub fn config_digest(&self) -> String {
use crate::plan::hash_fields;
use sha2::{Digest as _, Sha256};
let mut hasher = Sha256::new();
hash_fields::text(
&mut hasher,
b"generator_profile",
crate::manifest::GENERATOR_PROFILE_VERSION,
);
hash_fields::text(
&mut hasher,
b"nginx_template",
crate::manifest::NGINX_TEMPLATE_VERSION,
);
hash_fields::text(
&mut hasher,
b"static_version",
crate::identity::STATIC_VERSION,
);
hash_fields::text(&mut hasher, b"hostname", self.hostname.as_str());
hash_fields::text(&mut hasher, b"knot_did", self.knot_did.as_str());
hash_fields::text(&mut hasher, b"owner", self.owner.as_str());
hash_fields::text(&mut hasher, b"default_branch", self.default_branch.as_str());
hash_fields::text(
&mut hasher,
b"writes",
if self.writes.enabled { "on" } else { "off" },
);
hash_fields::text(
&mut hasher,
b"fastcgi_socket",
self.writes.fastcgi_socket.as_deref().unwrap_or(""),
);
hash_fields::text(
&mut hasher,
b"firehose_socket",
&self
.firehose_socket
.as_ref()
.map_or(String::new(), |socket| socket.display().to_string()),
);
hash_fields::text(
&mut hasher,
b"cgi_binary",
&self.writes.cgi_binary.display().to_string(),
);
hash_fields::text(
&mut hasher,
b"real_ip_from",
&self.activate.real_ip_from.join(" "),
);
let trusted: Vec<String> = self.serve.trusted.iter().map(IpPrefix::to_string).collect();
hash_fields::text(&mut hasher, b"trusted", &trusted.join(" "));
hash_fields::text(&mut hasher, b"trusted_rate", &self.serve.trusted_rate());
let limits = &self.serve.limits;
let off = || "off".to_string();
for (name, value) in [
(
"pack_rate",
limits.pack_rate.as_ref().map(ByteSize::to_string),
),
(
"pack_rate_after",
limits.pack_rate_after.as_ref().map(ByteSize::to_string),
),
(
"packs_in_flight",
limits.packs_in_flight.map(|n| n.to_string()),
),
("reads", limits.reads.as_ref().map(RequestRate::to_string)),
("reads_burst", Some(limits.reads_burst.to_string())),
(
"read_connections",
limits.read_connections.map(|n| n.to_string()),
),
(
"blob_connections",
limits.blob_connections.map(|n| n.to_string()),
),
(
"blob_rate",
limits.blob_rate.as_ref().map(ByteSize::to_string),
),
(
"blob_rate_after",
limits.blob_rate_after.as_ref().map(ByteSize::to_string),
),
(
"inflate_rate_all",
limits.inflate_rate_all.as_ref().map(RequestRate::to_string),
),
] {
hash_fields::text(&mut hasher, name.as_bytes(), &value.unwrap_or_else(off));
}
let index = &self.index;
for (name, value) in [
("index.title", &index.title),
("index.subtitle", &index.subtitle),
("index.description", &index.description),
("index.og_title", &index.og_title),
("index.og_subtitle", &index.og_subtitle),
("index.og_description", &index.og_description),
("index.links", &index.links),
] {
hash_fields::text(&mut hasher, name.as_bytes(), value);
}
hash_fields::field(&mut hasher, b"index.og_alt", index.og_alt.as_deref());
let fonts = index.fonts.as_ref().map(|fonts| {
let mut text = format!("{:?}\n{:?}", fonts.sans, fonts.mono);
for face in &fonts.faces {
text.push_str(&format!(
"\n{:?} {} {} {:?}",
face.family, face.weight, face.italic, face.url
));
}
text
});
hash_fields::field(&mut hasher, b"index.fonts", fonts.as_deref());
crate::cas::Digest::from_raw(sha2::Digest::finalize(hasher).into()).hex()
}
pub fn load(path: &Path) -> anyhow::Result<Self> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("read config {}", path.display()))?;
let raw: RawConfig =
toml::from_str(&raw).with_context(|| format!("parse config {}", path.display()))?;
let mut cfg =
Self::validated(raw).with_context(|| format!("validate config {}", path.display()))?;
cfg.config_path = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
Ok(cfg)
}
#[cfg(test)]
pub(crate) fn from_toml(text: &str) -> anyhow::Result<Self> {
Self::validated(toml::from_str(text)?)
}
fn validated(raw: RawConfig) -> anyhow::Result<Self> {
let hostname = KnotHostname::new(raw.knot.hostname).context("knot.hostname")?;
let knot_did = hostname.knot_did();
let owner = AccountDid::new(raw.knot.owner).context("knot.owner")?;
let default_branch = match raw.repo.default_branch {
Some(branch) => BranchName::new(branch).context("repo.default_branch")?,
None => BranchName::new("main".to_string()).expect("main is a valid branch name"),
};
let object_format = match raw.repo.object_format.as_deref() {
None => ObjectFormat::SHA1,
Some(token) => ObjectFormat::from_capability(token)
.with_context(|| format!("repo.object_format {token:?} is not sha1 or sha256"))?,
};
let plc_directory = url::Url::parse(&raw.atproto.plc_directory)
.context("atproto.plc_directory must be an absolute URL")?;
Ok(Self {
config_path: PathBuf::from("knot-static.toml"),
hostname,
knot_did,
owner,
scan_path: raw.repo.scan_path,
default_branch,
object_format,
sealed_key_file: raw.secrets.sealed_key_file,
master_key_env: raw.secrets.master_key_env,
plc_directory,
output_root: raw.output.root,
allow_private_addresses: raw.atproto.allow_private_addresses,
crawlers: raw
.crawl
.urls
.iter()
.map(|url| url::Url::parse(url).with_context(|| format!("crawl.urls entry {url}")))
.collect::<anyhow::Result<_>>()?,
activate: {
let activate = ActivateConfig {
nginx_binary: raw
.activate
.nginx_binary
.unwrap_or_else(|| "nginx".to_string()),
reload_command: raw.activate.reload_command,
listen: raw.activate.listen.unwrap_or_else(|| "8080".to_string()),
real_ip_from: raw
.activate
.real_ip_from
.into_iter()
.map(|proxy| {
IpPrefix::parse(&proxy, "activate.real_ip_from")?;
Ok(proxy)
})
.collect::<anyhow::Result<_>>()?,
keep_generations: {
let keep = raw
.activate
.keep_generations
.unwrap_or(DEFAULT_KEEP_GENERATIONS);
// a failed reload leaves old workers on the previous generation. keep at least one
anyhow::ensure!(keep >= 1, "activate.keep_generations must be at least 1");
keep
},
hooks: Hook::validate(raw.hooks)?,
};
if activate.real_ip_from.is_empty() && listens_on_loopback(&activate.listen) {
tracing::warn!(
listen = activate.listen,
"activate.real_ip_from is empty: every request will look like the loopback \
and no per-client limit will count it"
);
}
activate
},
writes: {
anyhow::ensure!(
!raw.writes.enabled || raw.writes.fastcgi_socket.is_some(),
"writes.enabled needs writes.fastcgi_socket"
);
WritesConfig {
enabled: raw.writes.enabled,
fastcgi_socket: raw.writes.fastcgi_socket,
cgi_binary: match raw.writes.cgi_binary {
Some(binary) => binary,
None => {
std::env::current_exe().context("locate this binary for the cgi")?
}
},
}
},
serve: ServeConfig {
trusted: raw
.serve
.trusted
.iter()
.map(|client| IpPrefix::parse(client, "serve.trusted"))
.collect::<anyhow::Result<_>>()?,
trusted_rate: size(raw.serve.trusted_rate, None, "serve.trusted_rate")?,
limits: {
let raw = raw.serve.limits;
let defaults = ServeLimits::default();
ServeLimits {
pack_rate: size(
raw.pack_rate,
defaults.pack_rate,
"serve.limits.pack_rate",
)?,
pack_rate_after: size(
raw.pack_rate_after,
defaults.pack_rate_after,
"serve.limits.pack_rate_after",
)?,
packs_in_flight: count(
raw.packs_in_flight,
defaults.packs_in_flight,
"serve.limits.packs_in_flight",
)?,
reads: request_rate(raw.reads, defaults.reads, "serve.limits.reads")?,
reads_burst: burst(
raw.reads_burst,
defaults.reads_burst,
"serve.limits.reads_burst",
)?,
read_connections: count(
raw.read_connections,
defaults.read_connections,
"serve.limits.read_connections",
)?,
blob_connections: count(
raw.blob_connections,
defaults.blob_connections,
"serve.limits.blob_connections",
)?,
blob_rate: size(
raw.blob_rate,
defaults.blob_rate,
"serve.limits.blob_rate",
)?,
blob_rate_after: size(
raw.blob_rate_after,
defaults.blob_rate_after,
"serve.limits.blob_rate_after",
)?,
inflate_rate_all: request_rate(
raw.inflate_rate_all,
defaults.inflate_rate_all,
"serve.limits.inflate_rate_all",
)?,
}
},
},
index: IndexConfig::from_raw(raw.index)?,
firehose_socket: match raw.firehose.socket {
// nginx reads `unix:<path>:`. a `:` or `$` in the path breaks it
Some(socket) => {
let text = socket.to_str().context("firehose.socket isn't utf-8")?;
anyhow::ensure!(
socket.is_absolute() && !text.contains([':', '$']),
"firehose.socket {text:?} must be an absolute path without ':' or '$'"
);
Some(socket)
}
None => None,
},
})
}
pub fn oauth_dir(&self) -> PathBuf {
self.sealed_key_file
.parent()
.unwrap_or_else(|| Path::new("."))
.join("oauth")
}
pub fn service_url(&self) -> anyhow::Result<url::Url> {
let mut url = url::Url::parse(&format!("https://{}", self.hostname))?;
url.set_path("");
Ok(url)
}
pub fn layout(&self) -> anyhow::Result<Layout> {
Layout::new(&self.scan_path)
.with_default_branch(self.default_branch.clone())
.with_object_format(self.object_format)
.reserving_meta(&self.knot_did)
.context("construct repo layout")
}
}