initial commit

This commit is contained in:
Charlotte 🦝 Delenk 2024-11-26 08:41:01 +01:00
commit 664f0bc626
Signed by: darkkirb
GPG key ID: AB2BD8DAF2E37122
30 changed files with 15669 additions and 0 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
/target
.env
config.toml
.direnv
target/
target-bin/
secrets/

3625
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

10505
Cargo.nix Normal file

File diff suppressed because it is too large Load diff

92
Cargo.toml Normal file
View file

@ -0,0 +1,92 @@
[workspace]
members = [
"chir-rs-config",
"chir-rs-db",
"chir-rs-gemini",
"chir-rs-http",
"chir-rs-http-api",
]
resolver = "2"
[package]
name = "chir-rs"
version = "0.1.0"
edition = "2021"
[dependencies]
chir-rs-config = { version = "0.1.0", path = "chir-rs-config" }
chir-rs-db = { version = "0.1.0", path = "chir-rs-db" }
chir-rs-gemini = { version = "0.1.0", path = "chir-rs-gemini" }
chir-rs-http = { version = "0.1.0", path = "chir-rs-http" }
color-eyre = { version = "0.6.3", features = ["issue-url"] }
dotenvy = "0.15.7"
eyre = "0.6.12"
sentry = { version = "0.34.0", default-features = false, features = [
"backtrace",
"contexts",
"debug-images",
"panic",
"metrics",
"reqwest",
"rustls",
] }
sentry-eyre = "0.2.0"
sentry-tracing = { version = "0.34.0", features = ["backtrace"] }
tokio = { version = "1.41.1", features = ["macros", "rt-multi-thread"] }
tracing-error = "0.2.0"
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json"] }
[lints.rust]
deprecated-safe = "forbid"
elided_lifetimes_in_paths = "warn"
explicit_outlives_requirements = "warn"
impl-trait-overcaptures = "warn"
keyword-idents-2024 = "forbid"
let-underscore-drop = "warn"
macro-use-extern-crate = "deny"
meta-variable-misuse = "deny"
missing-abi = "forbid"
missing-copy-implementations = "warn"
missing-debug-implementations = "deny"
missing-docs = "warn"
missing-unsafe-on-extern = "deny"
non-local-definitions = "warn"
redundant-lifetimes = "warn"
single-use-lifetimes = "warn"
trivial-casts = "warn"
trivial-numeric-casts = "warn"
unit-bindings = "deny"
unnameable-types = "warn"
unreachable-pub = "warn"
unsafe-code = "forbid"
unused-crate-dependencies = "warn"
unused-extern-crates = "warn"
unused-import-braces = "warn"
unused-lifetimes = "warn"
unused-macro-rules = "warn"
unused-qualifications = "warn"
variant-size-differences = "warn"
[lints.clippy]
nursery = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
module-name-repetitions = "allow"
alloc-instead-of-core = "warn"
allow-attributes-without-reason = "deny"
assertions-on-result-states = "forbid"
clone-on-ref-ptr = "warn"
empty-drop = "warn"
expect-used = "deny"
inline-asm-x86-att-syntax = "forbid"
missing-docs-in-private-items = "warn"
panic = "deny"
panic-in-result-fn = "forbid"
rc-buffer = "warn"
rc-mutex = "deny"
unwrap-used = "forbid"
[profile.release]
codegen-units = 1
lto = true
debug = "full"
strip = "none"

60
chir-rs-config/Cargo.toml Normal file
View file

@ -0,0 +1,60 @@
[package]
name = "chir-rs-config"
version = "0.1.0"
edition = "2021"
[dependencies]
atty = "0.2.14"
eyre = "0.6.12"
sentry-core = "0.34.0"
serde = { version = "1.0.215", features = ["derive"] }
toml = "0.8.19"
[lints.rust]
deprecated-safe = "forbid"
elided_lifetimes_in_paths = "warn"
explicit_outlives_requirements = "warn"
impl-trait-overcaptures = "warn"
keyword-idents-2024 = "forbid"
let-underscore-drop = "warn"
macro-use-extern-crate = "deny"
meta-variable-misuse = "deny"
missing-abi = "forbid"
missing-copy-implementations = "warn"
missing-debug-implementations = "deny"
missing-docs = "warn"
missing-unsafe-on-extern = "deny"
non-local-definitions = "warn"
redundant-lifetimes = "warn"
single-use-lifetimes = "warn"
trivial-casts = "warn"
trivial-numeric-casts = "warn"
unit-bindings = "deny"
unnameable-types = "warn"
unreachable-pub = "warn"
unsafe-code = "forbid"
unused-crate-dependencies = "warn"
unused-extern-crates = "warn"
unused-import-braces = "warn"
unused-lifetimes = "warn"
unused-macro-rules = "warn"
unused-qualifications = "warn"
variant-size-differences = "warn"
[lints.clippy]
nursery = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
module-name-repetitions = "allow"
alloc-instead-of-core = "warn"
allow-attributes-without-reason = "deny"
assertions-on-result-states = "forbid"
clone-on-ref-ptr = "warn"
empty-drop = "warn"
expect-used = "deny"
inline-asm-x86-att-syntax = "forbid"
missing-docs-in-private-items = "warn"
panic = "deny"
panic-in-result-fn = "forbid"
rc-buffer = "warn"
rc-mutex = "deny"
unwrap-used = "forbid"

197
chir-rs-config/src/lib.rs Normal file
View file

@ -0,0 +1,197 @@
//! Configuration file support
use std::{
net::SocketAddr,
path::{Path, PathBuf},
};
use eyre::{self, Context, Result};
use sentry_core::types::Dsn;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Returns the default logging level.
///
/// This is set to `debug` on debug builds and `warn` on release builds.
#[must_use]
fn default_log_level() -> String {
#[cfg(debug_assertions)]
{
"debug".to_string()
}
#[cfg(not(debug_assertions))]
{
"warn".to_string()
}
}
/// The Logging format to use
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum LogFormat {
/// Full human-readable logging output
Full,
/// Compact single-line logging format
Compact,
/// Pretty and sparse logging output, intended for development.
Pretty,
/// JSON output
Json,
}
impl Default for LogFormat {
fn default() -> Self {
if atty::is(atty::Stream::Stdout) {
#[cfg(debug_assertions)]
{
Self::Pretty
}
#[cfg(not(debug_assertions))]
{
Self::Full
}
} else {
Self::Json
}
}
}
/// Configuration for monitoring and logging
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
pub struct LoggingConfig {
/// Sentry DSN
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub sentry_dsn: Option<Dsn>,
/// Log Level to output to stdout.
/// By default, this is `"debug"` in debug builds and `"warn"` in release builds.
#[serde(default = "default_log_level")]
pub log_level: String,
/// Logging style used by tracing.
///
/// There are three default values:
///
/// - If the log output is not a tty (for example systemd-journald or a file), the log output will be [`LogFormat::Json`]
/// - If the log output is a tty AND the program runs in debug mode, the log output will be [`LogFormat::Pretty`]
/// - If the log output is a tty AND the program runs in release mode, the log output will be [`LogFormat::Full`]
#[serde(default)]
pub log_style: LogFormat,
}
fn deserialize_socket_addrs<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<SocketAddr>, D::Error> {
/// Internal representation of socket addresses
///
/// Its either a single one, or a list of them
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrVec {
/// Just a single socket address
One(SocketAddr),
/// List of socket addresses
Vec(Vec<SocketAddr>),
}
let one_or_vec: OneOrVec = OneOrVec::deserialize(d)?;
match one_or_vec {
OneOrVec::One(socket_addr) => Ok(vec![socket_addr]),
OneOrVec::Vec(vec) => Ok(vec),
}
}
fn serialize_socket_addrs<S: Serializer>(
socket_addrs: &Vec<SocketAddr>,
s: S,
) -> Result<S::Ok, S::Error> {
if socket_addrs.len() == 1 {
socket_addrs[0].serialize(s)
} else {
socket_addrs.serialize(s)
}
}
/// Returns the default socket addresses to use for http
#[expect(clippy::expect_used, reason = "Expect on hard-coded string literal")]
fn default_http_socket_addrs() -> Vec<SocketAddr> {
vec!["[::1]:5621"
.parse()
.expect("Hard coded string literal, should never happen")]
}
/// Returns the default socket addresses to use for gemini
#[expect(clippy::expect_used, reason = "Expect on hard-coded string literal")]
fn default_gemini_socket_addrs() -> Vec<SocketAddr> {
vec!["[::]:1965"
.parse()
.expect("Hard coded string literal, should never happen")]
}
/// Configuration used for axums HTTP config
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
pub struct Http {
/// Which IP addresses and ports to bind to
///
/// The default is `[::1]:5621`.
#[serde(serialize_with = "serialize_socket_addrs")]
#[serde(deserialize_with = "deserialize_socket_addrs")]
#[serde(default = "default_http_socket_addrs")]
pub listen: Vec<SocketAddr>,
}
/// Configuration used for the gemini server
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
pub struct Gemini {
/// Which IP addresses and ports to bind to
///
/// The default is `[::]:1965`.
#[serde(serialize_with = "serialize_socket_addrs")]
#[serde(deserialize_with = "deserialize_socket_addrs")]
#[serde(default = "default_gemini_socket_addrs")]
pub listen: Vec<SocketAddr>,
/// Host name to run under
pub host: String,
/// Path to the private key
pub private_key: PathBuf,
/// Path to the certificate
pub certificate: PathBuf,
}
/// Database configuration file
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
pub struct Database {
/// Path to the database
pub path: String,
}
/// Root configuration file
#[derive(Clone, Debug, PartialEq, Eq, Deserialize, Serialize)]
pub struct ChirRs {
/// Logging and monitoring related settings
pub logging: LoggingConfig,
/// HTTP Configuration
pub http: Http,
/// Gemini Configuration
pub gemini: Gemini,
/// Database Configuration
pub database: Database,
}
impl ChirRs {
/// Reads chir.rs configuration from file
///
/// # Errors
/// This function returns an error if the path cannot be read, doesnt contain valid UTF-8 text, or TOML in the expected configuration format.
pub fn read(fname: impl AsRef<Path>) -> Result<Self> {
let config = std::fs::read_to_string(fname.as_ref())
.with_context(|| format!("Reading config file {:?}", fname.as_ref()))?;
toml::de::from_str(&config)
.with_context(|| format!("Deserializing config file {:?}", fname.as_ref()))
}
/// Reads chir.rs configuration from a file pointed to by the `CHIR_RS_CONFIG` environment variable
///
/// # Errors
/// Like [`ChirRs::read`], but it also returns an error if the value of `CHIR_RS_CONFIG` is missing or invalid.
pub fn read_from_env() -> Result<Self> {
let fname = std::env::var("CHIR_RS_CONFIG")
.context("Reading CHIR_RS_CONFIG environment variable")?;
Self::read(fname)
}
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "DELETE FROM 'file' WHERE 'id' = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "3b729cf7297b569253249000a3cc0de67106e426a1ae4e7cdac6eb85eeeb3849"
}

View file

@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "UPDATE 'file' SET 'file_path' = ?, 'mime' = ?, 'b3hash' = ? WHERE 'id' = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 4
},
"nullable": []
},
"hash": "88de2bc8bea8b9b5f1de736af16ed3cc0a2711b37687e595619fa001fa2633bc"
}

67
chir-rs-db/Cargo.toml Normal file
View file

@ -0,0 +1,67 @@
[package]
name = "chir-rs-db"
version = "0.1.0"
edition = "2021"
[dependencies]
bincode = "2.0.0-rc.3"
serde = { version = "1.0.215", features = ["derive"] }
sqlx = { version = "0.8.2", features = [
"runtime-tokio",
"sqlite",
"derive",
"macros",
"migrate",
] }
eyre = "0.6.12"
tracing = "0.1.40"
blake3 = { version = "1.5.4", features = ["serde"] }
[lints.rust]
deprecated-safe = "forbid"
elided_lifetimes_in_paths = "warn"
explicit_outlives_requirements = "warn"
impl-trait-overcaptures = "warn"
keyword-idents-2024 = "forbid"
let-underscore-drop = "warn"
macro-use-extern-crate = "deny"
meta-variable-misuse = "deny"
missing-abi = "forbid"
missing-copy-implementations = "warn"
missing-debug-implementations = "deny"
missing-docs = "warn"
missing-unsafe-on-extern = "deny"
non-local-definitions = "warn"
redundant-lifetimes = "warn"
single-use-lifetimes = "warn"
trivial-casts = "warn"
trivial-numeric-casts = "warn"
unit-bindings = "deny"
unnameable-types = "warn"
unreachable-pub = "warn"
unsafe-code = "forbid"
unused-crate-dependencies = "warn"
unused-extern-crates = "warn"
unused-import-braces = "warn"
unused-lifetimes = "warn"
unused-macro-rules = "warn"
unused-qualifications = "warn"
variant-size-differences = "warn"
[lints.clippy]
nursery = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
module-name-repetitions = "allow"
alloc-instead-of-core = "warn"
allow-attributes-without-reason = "deny"
assertions-on-result-states = "forbid"
clone-on-ref-ptr = "warn"
empty-drop = "warn"
expect-used = "deny"
inline-asm-x86-att-syntax = "forbid"
missing-docs-in-private-items = "warn"
panic = "deny"
panic-in-result-fn = "forbid"
rc-buffer = "warn"
rc-mutex = "deny"
unwrap-used = "forbid"

6
chir-rs-db/build.rs Normal file
View file

@ -0,0 +1,6 @@
//! Ensures that migration additions force a rebuild
// generated by `sqlx migrate build-script`
fn main() {
// trigger recompilation when a new migration is added
println!("cargo:rerun-if-changed=migrations");
}

View file

@ -0,0 +1,10 @@
-- Add migration script here
create table 'file' (
id integer primary key not null,
file_path text not null,
mime text not null,
b3hash blob not null
);
create unique index file_path_mime on 'file' (file_path, mime);
create index file_path on 'file' (file_path);

190
chir-rs-db/src/file.rs Normal file
View file

@ -0,0 +1,190 @@
//! File related APIs
use bincode::{Decode, Encode};
use blake3::Hash;
use eyre::Context as _;
use eyre::Result;
use serde::{Deserialize, Serialize};
use sqlx::{prelude::FromRow, query_as, sqlite::SqliteRow};
use sqlx::{query, Row as _};
use tracing::instrument;
use crate::Database;
/// File record
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct File {
/// ID of the file record
id: u64,
/// Path this file is mounted at
pub file_path: String,
/// MIME type of file
pub mime: String,
/// blake3 hash of the file to serve
pub b3hash: Hash,
}
impl Encode for File {
fn encode<E: bincode::enc::Encoder>(
&self,
encoder: &mut E,
) -> std::result::Result<(), bincode::error::EncodeError> {
self.id.encode(encoder)?;
self.file_path.encode(encoder)?;
self.mime.encode(encoder)?;
self.b3hash.as_bytes().encode(encoder)
}
}
impl Decode for File {
fn decode<D: bincode::de::Decoder>(
decoder: &mut D,
) -> std::result::Result<Self, bincode::error::DecodeError> {
let id = u64::decode(decoder)?;
let file_path = String::decode(decoder)?;
let mime = String::decode(decoder)?;
let b3hash = <[u8; 32]>::decode(decoder)?;
Ok(Self {
id,
file_path,
mime,
b3hash: Hash::from_bytes(b3hash),
})
}
}
impl<'r> FromRow<'r, SqliteRow> for File {
fn from_row(row: &'r SqliteRow) -> std::result::Result<Self, sqlx::Error> {
let id = u64::try_from(row.try_get::<i64, _>("id")?).unwrap_or_default();
let file_path: String = row.try_get("file_path")?;
let mime: String = row.try_get("mime")?;
let b3hash: Vec<u8> = row.try_get("b3hash")?;
if b3hash.len() != 32 {
return Err(sqlx::Error::ColumnDecode {
index: "b3hash".to_string(),
source: Box::new(std::io::Error::other("invalid b3 hash len")),
});
}
let mut b3hash_arr = [0u8; 32];
b3hash_arr.copy_from_slice(&b3hash);
Ok(Self {
id,
file_path,
mime,
b3hash: Hash::from_bytes(b3hash_arr),
})
}
}
impl File {
/// Attempts to load a file by path and mime type
///
/// # Errors
/// This function returns an error if a database error occurs while loading.
#[instrument(skip(db))]
pub async fn get_by_path_mime(db: &Database, path: &str, mime: &str) -> Result<Option<Self>> {
query_as("SELECT * FROM 'file' WHERE 'path' = ? AND 'mime' = ?")
.bind(path)
.bind(mime)
.fetch_optional(&*db.0)
.await
.with_context(|| format!("Loading file path {path} with mime type {mime}"))
}
/// Attempts to load any files by path.
///
/// # Errors
/// This function returns an error if a database error occurs while loading.
#[instrument(skip(db))]
pub async fn get_by_path(db: &Database, path: &str) -> Result<Vec<Self>> {
query_as("SELECT * FROM 'file' WHERE 'path' = ?")
.bind(path)
.fetch_all(&*db.0)
.await
.with_context(|| format!("Loading files with path {path}"))
}
/// Returns a paginated view into the file table
///
/// # Errors
/// This function returns an error if a database error occurs while loading.
#[instrument(skip(db))]
pub async fn list(db: &Database, after: i64, limit: usize) -> Result<Vec<Self>> {
let limit: i64 = limit.min(100).try_into().unwrap_or(100); // reasonable limit for pagination size
query_as("SELECT * FROM 'file' WHERE id > ? LIMIT ?")
.bind(after)
.bind(limit)
.fetch_all(&*db.0)
.await
.with_context(|| format!("Loading up to {limit} files after id {after}"))
}
/// Creates a new file
///
/// # Errors
/// This function returns an error if a database error occurs when writing, or there is a conflict
#[instrument(skip(db))]
pub async fn new(db: &Database, path: &str, mime: &str, hash: &Hash) -> Result<()> {
query_as("INSERT INTO 'file' ('file_path', 'mime', 'b3hash') VALUES (?, ?, ?) RETURNING *")
.bind(path)
.bind(mime)
.bind(hash.as_bytes().as_slice())
.fetch_one(&*db.0)
.await
.with_context(|| format!("Inserting new file {path} with mime type {mime}"))
}
/// Deletes a file record from the database. This does not perform any actual file deletion.
///
/// # Errors
/// This function returns an error if removing the entry from the database fails.
#[instrument(skip(db))]
pub async fn delete(self, db: &Database) -> Result<()> {
let id: i64 = self.id.try_into()?;
query!("DELETE FROM 'file' WHERE 'id' = ?", id)
.execute(&*db.0)
.await
.with_context(|| {
format!(
"Deleting file {} with mime type {}",
self.file_path, self.mime
)
})?;
Ok(())
}
/// Returns the immutable ID of the object
#[must_use]
pub const fn id(&self) -> u64 {
self.id
}
/// Updates the file with new information
///
/// # Errors
/// THis function returns an error if updating the entry in the database fails
#[instrument(skip(db))]
pub async fn update(&self, db: &Database) -> Result<()> {
let id: i64 = self.id.try_into()?;
let b3hash = self.b3hash.as_bytes().as_slice();
query!(
"UPDATE 'file' SET 'file_path' = ?, 'mime' = ?, 'b3hash' = ? WHERE 'id' = ?",
self.file_path,
self.mime,
b3hash,
id
)
.execute(&*db.0)
.await
.with_context(|| {
format!(
"Deleting file {} with mime type {}",
self.file_path, self.mime
)
})?;
Ok(())
}
}

27
chir-rs-db/src/lib.rs Normal file
View file

@ -0,0 +1,27 @@
//! Chir.rs database models
use std::sync::Arc;
use eyre::{Context, Result};
use sqlx::{migrate, SqlitePool};
use tracing::instrument;
pub mod file;
/// Opaque database handle
#[derive(Clone, Debug)]
#[repr(transparent)]
pub struct Database(Arc<SqlitePool>);
/// Opens the database
///
/// # Errors
/// This function returns an error if a connection to the database could not be established
#[instrument]
pub async fn open_database(path: &str) -> Result<Database> {
let pool = SqlitePool::connect(path)
.await
.with_context(|| format!("Opening database {path}"))?;
migrate!().run(&pool).await?;
Ok(Database(Arc::new(pool)))
}

62
chir-rs-gemini/Cargo.toml Normal file
View file

@ -0,0 +1,62 @@
[package]
name = "chir-rs-gemini"
version = "0.1.0"
edition = "2021"
[dependencies]
bytes = "1.8.0"
chir-rs-config = { version = "0.1.0", path = "../chir-rs-config" }
chir-rs-db = { version = "0.1.0", path = "../chir-rs-db" }
eyre = "0.6.12"
rustls = "0.23.18"
tokio = { version = "1.41.1", features = ["net"] }
tokio-rustls = "0.26.0"
tracing = "0.1.40"
[lints.rust]
deprecated-safe = "forbid"
elided_lifetimes_in_paths = "warn"
explicit_outlives_requirements = "warn"
impl-trait-overcaptures = "warn"
keyword-idents-2024 = "forbid"
let-underscore-drop = "warn"
macro-use-extern-crate = "deny"
meta-variable-misuse = "deny"
missing-abi = "forbid"
missing-copy-implementations = "warn"
missing-debug-implementations = "deny"
missing-docs = "warn"
missing-unsafe-on-extern = "deny"
non-local-definitions = "warn"
redundant-lifetimes = "warn"
single-use-lifetimes = "warn"
trivial-casts = "warn"
trivial-numeric-casts = "warn"
unit-bindings = "deny"
unnameable-types = "warn"
unreachable-pub = "warn"
unsafe-code = "forbid"
unused-crate-dependencies = "warn"
unused-extern-crates = "warn"
unused-import-braces = "warn"
unused-lifetimes = "warn"
unused-macro-rules = "warn"
unused-qualifications = "warn"
variant-size-differences = "warn"
[lints.clippy]
nursery = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
module-name-repetitions = "allow"
alloc-instead-of-core = "warn"
allow-attributes-without-reason = "deny"
assertions-on-result-states = "forbid"
clone-on-ref-ptr = "warn"
empty-drop = "warn"
expect-used = "deny"
inline-asm-x86-att-syntax = "forbid"
missing-docs-in-private-items = "warn"
panic = "deny"
panic-in-result-fn = "forbid"
rc-buffer = "warn"
rc-mutex = "deny"
unwrap-used = "forbid"

53
chir-rs-gemini/src/lib.rs Normal file
View file

@ -0,0 +1,53 @@
//! Gemini server implementation for chir.rs
use std::sync::Arc;
use bytes::BytesMut;
use chir_rs_config::ChirRs;
use chir_rs_db::Database;
use eyre::Result;
use rustls::pki_types::{pem::PemObject, CertificateDer, PrivateKeyDer};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
};
use tokio_rustls::TlsAcceptor;
use tracing::{error, info};
/// entrypoint for the gemini server
///
/// # Errors
///
/// This function returns an error if starting the gemini server fails
pub async fn main(cfg: Arc<ChirRs>, _: Database) -> Result<()> {
let certs =
CertificateDer::pem_file_iter(&cfg.gemini.certificate)?.collect::<Result<Vec<_>, _>>()?;
let key = PrivateKeyDer::from_pem_file(&cfg.gemini.private_key)?;
let config = rustls::ServerConfig::builder_with_provider(Arc::new(
rustls::crypto::aws_lc_rs::default_provider(),
))
.with_safe_default_protocol_versions()?
.with_no_client_auth()
.with_single_cert(certs, key)?;
let acceptor = TlsAcceptor::from(Arc::new(config));
let listener = TcpListener::bind(&*cfg.gemini.listen).await?;
info!("Starting Gemini server on {:?}", cfg.gemini.listen);
loop {
let (stream, _peer_addr) = listener.accept().await?;
let acceptor = acceptor.clone();
let fut = async move {
let mut stream = acceptor.accept(stream).await?;
let mut request = BytesMut::with_capacity(4096);
stream.read_buf(&mut request).await?;
println!("{request:?}");
stream.write_all(b"51\r\n").await?;
stream.shutdown().await?;
Ok::<_, eyre::Report>(())
};
tokio::spawn(async move {
if let Err(err) = fut.await {
error!("Failed to handle request: {err:?}");
}
});
}
}

View file

@ -0,0 +1,66 @@
[package]
name = "chir-rs-http-api"
version = "0.1.0"
edition = "2021"
[features]
axum = ["axum-core", "async-trait", "bytes", "mime", "tracing"]
[dependencies]
async-trait = { version = "0.1.83", optional = true }
axum-core = { version = "0.4.5", optional = true }
bincode = "2.0.0-rc.3"
bytes = { version = "1.8.0", optional = true }
http = "1.1.0"
mime = { version = "0.3.17", optional = true }
thiserror = "2.0.3"
tracing = { version = "0.1.40", optional = true }
[lints.rust]
deprecated-safe = "forbid"
elided_lifetimes_in_paths = "warn"
explicit_outlives_requirements = "warn"
impl-trait-overcaptures = "warn"
keyword-idents-2024 = "forbid"
let-underscore-drop = "warn"
macro-use-extern-crate = "deny"
meta-variable-misuse = "deny"
missing-abi = "forbid"
missing-copy-implementations = "warn"
missing-debug-implementations = "deny"
missing-docs = "warn"
missing-unsafe-on-extern = "deny"
non-local-definitions = "warn"
redundant-lifetimes = "warn"
single-use-lifetimes = "warn"
trivial-casts = "warn"
trivial-numeric-casts = "warn"
unit-bindings = "deny"
unnameable-types = "warn"
unreachable-pub = "warn"
unsafe-code = "forbid"
unused-crate-dependencies = "warn"
unused-extern-crates = "warn"
unused-import-braces = "warn"
unused-lifetimes = "warn"
unused-macro-rules = "warn"
unused-qualifications = "warn"
variant-size-differences = "warn"
[lints.clippy]
nursery = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
module-name-repetitions = "allow"
alloc-instead-of-core = "warn"
allow-attributes-without-reason = "deny"
assertions-on-result-states = "forbid"
clone-on-ref-ptr = "warn"
empty-drop = "warn"
expect-used = "deny"
inline-asm-x86-att-syntax = "forbid"
missing-docs-in-private-items = "warn"
panic = "deny"
panic-in-result-fn = "forbid"
rc-buffer = "warn"
rc-mutex = "deny"
unwrap-used = "forbid"

View file

@ -0,0 +1,88 @@
//! Binary serialization format
use async_trait::async_trait;
use axum_core::{
extract::{FromRequest, Request},
response::{IntoResponse, Response},
};
use bincode::{Decode, Encode};
use bytes::{BufMut as _, Bytes, BytesMut};
use http::{header::CONTENT_TYPE, HeaderValue, StatusCode};
use tracing::error;
use crate::errors::APIError;
/// Bincode wrapper
#[derive(Debug, Clone, Copy, Default)]
#[repr(transparent)]
#[must_use]
pub struct Bincode<T>(pub T);
impl IntoResponse for APIError {
fn into_response(self) -> Response {
(self.status_code(), Bincode(self)).into_response()
}
}
#[async_trait]
impl<T, S> FromRequest<S> for Bincode<T>
where
T: Decode,
S: Send + Sync,
{
type Rejection = APIError;
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
match req.headers().get(CONTENT_TYPE) {
Some(c) if c == "application/x+bincode" => {
let bytes = Bytes::from_request(req, state).await?;
match bincode::decode_from_slice(&bytes, bincode::config::standard()) {
Ok(v) => Ok(Self(v.0)),
Err(e) => {
error!("Body decode error: {e:?}");
Err(APIError::PayloadInvalid)
}
}
}
Some(c) => Err(APIError::ClientInvalidContentType {
expected: "application/x+bincode".to_string(),
received: c
.to_str()
.map_or_else(|_| format!("{c:?}"), ToString::to_string),
}),
_ => Err(APIError::ClientMissingContentType {
expected: "application/x+bincode".to_string(),
}),
}
}
}
impl<T> IntoResponse for Bincode<T>
where
T: Encode,
{
fn into_response(self) -> Response {
let mut buf = BytesMut::with_capacity(128).writer();
match bincode::encode_into_std_write(self.0, &mut buf, bincode::config::standard()) {
Ok(_) => (
[(
CONTENT_TYPE,
HeaderValue::from_static("application/x+bincode"),
)],
buf.into_inner().freeze(),
)
.into_response(),
Err(err) => {
error!("Failed to encode bincode response: {err:?}");
(
StatusCode::INTERNAL_SERVER_ERROR,
[(
CONTENT_TYPE,
HeaderValue::from_static(mime::TEXT_PLAIN_UTF_8.as_ref()),
)],
"internal server error".to_string(),
)
.into_response()
}
}
}
}

View file

@ -0,0 +1,40 @@
//! Error type impls
use axum_core::extract::rejection::{
BytesRejection, FailedToBufferBody, LengthLimitError, UnknownBodyError,
};
use crate::errors::APIError;
impl From<LengthLimitError> for APIError {
fn from(_: LengthLimitError) -> Self {
Self::PayloadTooBig
}
}
impl From<UnknownBodyError> for APIError {
fn from(_: UnknownBodyError) -> Self {
Self::PayloadLoadError
}
}
impl From<FailedToBufferBody> for APIError {
fn from(value: FailedToBufferBody) -> Self {
match value {
FailedToBufferBody::LengthLimitError(length_limit_error) => length_limit_error.into(),
FailedToBufferBody::UnknownBodyError(unknown_body_error) => unknown_body_error.into(),
rest => Self::Unknown(format!("Unknown FailedToBufferBody: {rest:?}")),
}
}
}
impl From<BytesRejection> for APIError {
fn from(value: BytesRejection) -> Self {
match value {
BytesRejection::FailedToBufferBody(failed_to_buffer_body) => {
failed_to_buffer_body.into()
}
rest => Self::Unknown(format!("Unknown BytesRejection: {rest:?}")),
}
}
}

View file

@ -0,0 +1,4 @@
//! Axum support
pub mod bincode;
mod error;

View file

@ -0,0 +1,51 @@
//! Main error type
use bincode::{Decode, Encode};
use http::StatusCode;
use thiserror::Error;
/// The main error type
#[derive(Clone, Debug, PartialEq, Eq, Encode, Decode, Error)]
pub enum APIError {
/// Returned when the client sends the wrong content type to the server.
#[error("Invalid content type: Expected {expected}, Received {received}")]
ClientInvalidContentType {
/// Expected value of the content type
expected: String,
/// Received value of the content type
received: String,
},
/// Returned when the client does not send a content type header
#[error("Missing content type: Expected {expected}")]
ClientMissingContentType {
/// Expected value of the content type
expected: String,
},
/// Returned when the client payload is too large.
#[error("Invalid payload: Too large")]
PayloadTooBig,
/// Returned when there is an unknown error loading the client payloud
#[error("Failed to load payload")]
PayloadLoadError,
/// Returned when the client payload is malformed
#[error("Invalid payload")]
PayloadInvalid,
/// Returned when the error is unknown
#[error("Unknown Error")]
Unknown(String),
}
impl APIError {
/// Returns the HTTP Status code of the error
#[must_use]
pub const fn status_code(&self) -> StatusCode {
match *self {
Self::ClientInvalidContentType { .. } | Self::ClientMissingContentType { .. } => {
StatusCode::UNSUPPORTED_MEDIA_TYPE
}
Self::PayloadTooBig => StatusCode::PAYLOAD_TOO_LARGE,
Self::PayloadLoadError | Self::PayloadInvalid => StatusCode::BAD_REQUEST,
Self::Unknown(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
}

View file

@ -0,0 +1,6 @@
//! API Type Definitions for chir.rs
#[cfg(feature = "axum")]
pub mod axum;
pub mod errors;
pub mod readiness;

View file

@ -0,0 +1,12 @@
//! APIs for testing readiness
use bincode::{Decode, Encode};
/// Current Ready State
#[derive(Copy, Clone, Debug, PartialEq, Eq, Encode, Decode)]
pub enum ReadyState {
/// Indicates that this service is ready to receive requests
Ready,
/// Indicates that this service is not yet ready to receive requests
NotReady,
}

67
chir-rs-http/Cargo.toml Normal file
View file

@ -0,0 +1,67 @@
[package]
name = "chir-rs-http"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.7.9", features = ["tracing"] }
axum-prometheus = "0.7.0"
chir-rs-config = { version = "0.1.0", path = "../chir-rs-config" }
chir-rs-db = { version = "0.1.0", path = "../chir-rs-db" }
chir-rs-http-api = { version = "0.1.0", path = "../chir-rs-http-api", features = [
"axum",
] }
eyre = "0.6.12"
sentry-tower = { version = "0.34.0", features = ["axum", "axum-matched-path"] }
tokio = { version = "1.41.1", features = ["net"] }
tower-http = { version = "0.6.2", features = ["trace"] }
tracing = "0.1.40"
[lints.rust]
deprecated-safe = "forbid"
elided_lifetimes_in_paths = "warn"
explicit_outlives_requirements = "warn"
impl-trait-overcaptures = "warn"
keyword-idents-2024 = "forbid"
let-underscore-drop = "warn"
macro-use-extern-crate = "deny"
meta-variable-misuse = "deny"
missing-abi = "forbid"
missing-copy-implementations = "warn"
missing-debug-implementations = "deny"
missing-docs = "warn"
missing-unsafe-on-extern = "deny"
non-local-definitions = "warn"
redundant-lifetimes = "warn"
single-use-lifetimes = "warn"
trivial-casts = "warn"
trivial-numeric-casts = "warn"
unit-bindings = "deny"
unnameable-types = "warn"
unreachable-pub = "warn"
unsafe-code = "forbid"
unused-crate-dependencies = "warn"
unused-extern-crates = "warn"
unused-import-braces = "warn"
unused-lifetimes = "warn"
unused-macro-rules = "warn"
unused-qualifications = "warn"
variant-size-differences = "warn"
[lints.clippy]
nursery = { level = "warn", priority = -1 }
pedantic = { level = "warn", priority = -1 }
module-name-repetitions = "allow"
alloc-instead-of-core = "warn"
allow-attributes-without-reason = "deny"
assertions-on-result-states = "forbid"
clone-on-ref-ptr = "warn"
empty-drop = "warn"
expect-used = "deny"
inline-asm-x86-att-syntax = "forbid"
missing-docs-in-private-items = "warn"
panic = "deny"
panic-in-result-fn = "forbid"
rc-buffer = "warn"
rc-mutex = "deny"
unwrap-used = "forbid"

73
chir-rs-http/src/lib.rs Normal file
View file

@ -0,0 +1,73 @@
//! HTTP server implementation for chir-rs
use std::sync::Arc;
use axum::{
extract::{MatchedPath, Request, State},
routing::get,
Router,
};
use axum_prometheus::PrometheusMetricLayer;
use chir_rs_config::ChirRs;
use chir_rs_db::{file::File, Database};
use chir_rs_http_api::{axum::bincode::Bincode, readiness::ReadyState};
use eyre::{Context, Result};
use tokio::net::TcpListener;
use tower_http::trace::TraceLayer;
use tracing::{info, info_span};
/// Application state
#[derive(Clone, Debug)]
pub struct AppState {
/// Database handle
pub db: Database,
}
/// Entrypoint for the HTTP server component
///
/// # Errors
/// This function returns an error if the startup of the server fails.
///
/// Errors it encounters during runtime should be automatically handled.
pub async fn main(cfg: Arc<ChirRs>, db: Database) -> Result<()> {
let (prometheus_layer, metric_handle) = PrometheusMetricLayer::pair();
let app = Router::new()
// Routes here
.route("/.api/readyz", get(|| async { Bincode(ReadyState::Ready) }))
.route(
"/.api/metrics",
get(|| async move { metric_handle.render() }),
)
.route(
"/",
get(|State(state): State<AppState>| async move {
Bincode(File::list(&state.db, 0, 100).await.ok())
}),
)
.with_state(AppState { db })
.layer(
TraceLayer::new_for_http().make_span_with(|request: &Request<_>| {
let matched_path = request
.extensions()
.get::<MatchedPath>()
.map(MatchedPath::as_str);
info_span!(
"http_request",
method = ?request.method(),
matched_path,
)
}),
)
.layer(prometheus_layer)
.layer(sentry_tower::NewSentryLayer::<Request>::new_from_top())
.layer(sentry_tower::SentryHttpLayer::with_transaction());
let listener = TcpListener::bind(&*cfg.http.listen)
.await
.with_context(|| format!("Binding to TCP {:?}", cfg.http.listen))?;
info!("Starting HTTP server on {:?}", cfg.http.listen);
axum::serve(listener, app)
.await
.context("Starting Axum Server")?;
Ok(())
}

15
config-example.toml Normal file
View file

@ -0,0 +1,15 @@
[logging]
#sentry_dsn = "…"
log_level = "debug"
log_style = "Pretty"
[http]
listen = "[::1]:5621"
# Alternatively:
# listen = ["127.0.0.1:5621", "10.0.0.0:12345", "[::1]:5621"]
[gemini]
listen = "[::]:1965"
host = "lotte.chir.rs"
private_key = "secrets/server.key"
certificate = "secrets/server.crt"
[database]
path = "secrets/test.db"

141
flake.lock Normal file
View file

@ -0,0 +1,141 @@
{
"nodes": {
"cargo2nix": {
"inputs": {
"flake-compat": "flake-compat",
"flake-utils": [
"flake-utils"
],
"nixpkgs": [
"nixpkgs"
],
"rust-overlay": [
"rust-overlay"
]
},
"locked": {
"lastModified": 1726552619,
"narHash": "sha256-ytTBILVMnRZYvjiLYz+J6IFf/TOXdGuP6RDesMx9qgA=",
"owner": "DarkKirb",
"repo": "cargo2nix",
"rev": "baa12124e2de09e1cbbdac320f14809fa55af1a2",
"type": "github"
},
"original": {
"owner": "DarkKirb",
"ref": "master",
"repo": "cargo2nix",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1696426674,
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"flake-compat_2": {
"locked": {
"lastModified": 1696426674,
"narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=",
"rev": "0f9255e01c2351cc7d116c072cb317785dd33b33",
"revCount": 57,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/edolstra/flake-compat/1.0.1/018afb31-abd1-7bff-a5e4-cff7e18efb7a/source.tar.gz?rev=0f9255e01c2351cc7d116c072cb317785dd33b33&revCount=57"
},
"original": {
"type": "tarball",
"url": "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1732604498,
"narHash": "sha256-jLSaysOTd7nj4sR0FG5ruTz2QBBPR/dInANBIDtJLog=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "096ede7f2b180f03d53fae3d69e4f0d84e199825",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"cargo2nix": "cargo2nix",
"flake-compat": "flake-compat_2",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1732588352,
"narHash": "sha256-J2/hxOO1VtBA/u+a+9E+3iJpWT3xsBdghgYAVfoGCJo=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "414e748aae5c9e6ca63c5aafffda03e5dad57ceb",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

73
flake.nix Normal file
View file

@ -0,0 +1,73 @@
{
description = "rust-template";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
cargo2nix = {
url = "github:DarkKirb/cargo2nix/master";
inputs.nixpkgs.follows = "nixpkgs";
inputs.flake-utils.follows = "flake-utils";
inputs.rust-overlay.follows = "rust-overlay";
};
flake-compat.url = "https://flakehub.com/f/edolstra/flake-compat/1.tar.gz";
};
outputs =
{
self,
nixpkgs,
flake-utils,
rust-overlay,
cargo2nix,
...
}@inputs:
flake-utils.lib.eachSystem [ "x86_64-linux" ] (
system:
let
overlays = [
cargo2nix.overlays.default
(import rust-overlay)
];
pkgs = import nixpkgs {
inherit system overlays;
};
rustPkgs = pkgs.rustBuilder.makePackageSet {
packageFun = import ./Cargo.nix;
rustChannel = "stable";
rustVersion = "latest";
packageOverrides = pkgs: pkgs.rustBuilder.overrides.all;
};
in
rec {
devShells.default =
with pkgs;
mkShell {
buildInputs = [
cargo2nix.packages.${system}.cargo2nix
rustfilt
gdb
sqlx-cli
cargo-expand
sqlite
];
};
packages = pkgs.lib.mapAttrs (_: v: (v { }).overrideAttrs { dontStrip = true; }) rustPkgs.workspace;
nixosModules.default = import ./nixos {
inherit inputs system;
};
checks = pkgs.lib.mapAttrs (_: v: pkgs.rustBuilder.runTests v { }) rustPkgs.workspace;
hydraJobs = {
inherit packages checks;
};
formatter = pkgs.nixfmt-rfc-style;
}
);
}
# Trick renovate into working: "github:NixOS/nixpkgs/nixpkgs-unstable"

4
gencert.sh Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env nix-shell
#! nix-shell -p openssh -i bash
openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout secrets/server.key -out secrets/server.crt

103
src/main.rs Normal file
View file

@ -0,0 +1,103 @@
//! Main entrypoint for the chir-rs web server
use core::str::FromStr;
use std::sync::Arc;
use chir_rs_config::ChirRs;
use eyre::{Context, Result};
// implicitly used
use sentry_eyre as _;
use tokio::try_join;
use tracing_error::ErrorLayer;
use tracing_subscriber::{
fmt::format::JsonFields, layer::SubscriberExt as _, util::SubscriberInitExt as _, Layer,
};
fn main() -> Result<()> {
color_eyre::install().ok();
dotenvy::dotenv().ok();
// NO THREADS BEFORE THIS POINT
let cfg = ChirRs::read_from_env().context("Reading chir.rs configuration")?;
let _guard = sentry::init(sentry::ClientOptions {
dsn: cfg.logging.sentry_dsn.clone(),
release: sentry::release_name!(),
traces_sample_rate: 1.0,
attach_stacktrace: true,
debug: cfg!(debug_assertions),
..Default::default()
});
let log_filter = tracing_subscriber::EnvFilter::from_str(&cfg.logging.log_level)
.with_context(|| format!("Setting log filter to {}", cfg.logging.log_level))?;
match cfg.logging.log_style {
chir_rs_config::LogFormat::Full => {
let log_format = tracing_subscriber::fmt::format();
tracing_subscriber::registry()
.with(
tracing_subscriber::fmt::layer()
.event_format(log_format)
.with_filter(log_filter),
)
.with(ErrorLayer::default())
.with(sentry_tracing::layer())
.init();
}
chir_rs_config::LogFormat::Compact => {
let log_format = tracing_subscriber::fmt::format().compact();
tracing_subscriber::registry()
.with(
tracing_subscriber::fmt::layer()
.event_format(log_format)
.with_filter(log_filter),
)
.with(ErrorLayer::default())
.with(sentry_tracing::layer())
.init();
}
chir_rs_config::LogFormat::Pretty => {
let log_format = tracing_subscriber::fmt::format().pretty();
tracing_subscriber::registry()
.with(
tracing_subscriber::fmt::layer()
.event_format(log_format)
.with_filter(log_filter),
)
.with(ErrorLayer::default())
.with(sentry_tracing::layer())
.init();
}
chir_rs_config::LogFormat::Json => {
let log_format = tracing_subscriber::fmt::format().json();
tracing_subscriber::registry()
.with(
tracing_subscriber::fmt::layer()
.event_format(log_format)
.fmt_fields(JsonFields::new())
.with_filter(log_filter),
)
.with(ErrorLayer::default())
.with(sentry_tracing::layer())
.init();
}
}
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.context("Building thread pool for tokio")?
.block_on(async move {
let cfg = Arc::new(cfg);
let db = chir_rs_db::open_database(&cfg.database.path).await?;
try_join!(
chir_rs_http::main(Arc::clone(&cfg), db.clone()),
chir_rs_gemini::main(Arc::clone(&cfg), db.clone())
)
.context("Starting server components")?;
Ok::<_, eyre::Report>(())
})
.context("Running chir.rs")
}