Support for gemini fully fleshed out

This commit is contained in:
Brian Picciano 2023-07-31 20:46:54 +02:00
parent d429b51cf8
commit 96b38f2c97
12 changed files with 451 additions and 137 deletions

100
Cargo.lock generated
View File

@ -111,6 +111,12 @@ version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6" checksum = "bddcadddf5e9015d310179a59bb28c4d4b9920ad0f11e8e14dbadf654890c9a6"
[[package]]
name = "arrayvec"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b"
[[package]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.7.2" version = "0.7.2"
@ -158,6 +164,18 @@ version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24a6904aef64d73cf10ab17ebace7befb918b82164785cb89907993be7f83813" checksum = "24a6904aef64d73cf10ab17ebace7befb918b82164785cb89907993be7f83813"
[[package]]
name = "bitvec"
version = "0.19.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55f93d0ef3363c364d5976646a38f04cf67cfe1d4c8d160cdea02cab2c116b33"
dependencies = [
"funty",
"radium",
"tap",
"wyz",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@ -458,9 +476,11 @@ name = "domani"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"acme2", "acme2",
"bytes",
"clap", "clap",
"env_logger", "env_logger",
"futures", "futures",
"gemini",
"gix", "gix",
"handlebars", "handlebars",
"hex", "hex",
@ -660,6 +680,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" checksum = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba"
[[package]]
name = "funty"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fed34cd105917e91daa4da6b3728c47b068749d6a62c59811f06ed2ac71d9da7"
[[package]] [[package]]
name = "futures" name = "futures"
version = "0.3.28" version = "0.3.28"
@ -749,6 +775,18 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "gemini"
version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12a820f5a9ac6f433b34944dc8d17b759d5009275c8fe12f73b873153dbcd4e0"
dependencies = [
"nom 6.1.2",
"paste",
"thiserror",
"url",
]
[[package]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.7" version = "0.14.7"
@ -828,7 +866,7 @@ dependencies = [
"btoi", "btoi",
"gix-date", "gix-date",
"itoa", "itoa",
"nom", "nom 7.1.3",
"thiserror", "thiserror",
] ]
@ -891,7 +929,7 @@ dependencies = [
"gix-sec", "gix-sec",
"log", "log",
"memchr", "memchr",
"nom", "nom 7.1.3",
"once_cell", "once_cell",
"smallvec", "smallvec",
"thiserror", "thiserror",
@ -1100,7 +1138,7 @@ dependencies = [
"gix-validate", "gix-validate",
"hex", "hex",
"itoa", "itoa",
"nom", "nom 7.1.3",
"smallvec", "smallvec",
"thiserror", "thiserror",
] ]
@ -1195,7 +1233,7 @@ dependencies = [
"gix-hash", "gix-hash",
"gix-transport", "gix-transport",
"maybe-async", "maybe-async",
"nom", "nom 7.1.3",
"thiserror", "thiserror",
] ]
@ -1226,7 +1264,7 @@ dependencies = [
"gix-tempfile", "gix-tempfile",
"gix-validate", "gix-validate",
"memmap2", "memmap2",
"nom", "nom 7.1.3",
"thiserror", "thiserror",
] ]
@ -1767,6 +1805,19 @@ version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "lexical-core"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6607c62aa161d23d17a9072cc5da0be67cdfc89d3afb1e8d9c842bebc2525ffe"
dependencies = [
"arrayvec 0.5.2",
"bitflags 1.3.2",
"cfg-if",
"ryu",
"static_assertions",
]
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.142" version = "0.2.142"
@ -1954,6 +2005,19 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "nom"
version = "6.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7413f999671bd4745a7b624bd370a569fb6bc574b23c83a3c5ed2e453f3d5e2"
dependencies = [
"bitvec",
"funty",
"lexical-core",
"memchr",
"version_check",
]
[[package]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@ -2081,6 +2145,12 @@ dependencies = [
"windows-sys 0.45.0", "windows-sys 0.45.0",
] ]
[[package]]
name = "paste"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c"
[[package]] [[package]]
name = "pem" name = "pem"
version = "2.0.1" version = "2.0.1"
@ -2249,6 +2319,12 @@ dependencies = [
"proc-macro2", "proc-macro2",
] ]
[[package]]
name = "radium"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "941ba9d78d8e2f7ce474c015eea4d9c6d25b6a3327f9832ee29a4de27f91bbb8"
[[package]] [[package]]
name = "radix_trie" name = "radix_trie"
version = "0.2.1" version = "0.2.1"
@ -2805,6 +2881,12 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "tap"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]] [[package]]
name = "tempdir" name = "tempdir"
version = "0.3.7" version = "0.3.7"
@ -3121,7 +3203,7 @@ version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "794a32261a1f5eb6a4462c81b59cec87b5c27d5deea7dd1ac8fc781c41d226db" checksum = "794a32261a1f5eb6a4462c81b59cec87b5c27d5deea7dd1ac8fc781c41d226db"
dependencies = [ dependencies = [
"arrayvec", "arrayvec 0.7.2",
] ]
[[package]] [[package]]
@ -3508,3 +3590,9 @@ checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d"
dependencies = [ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "wyz"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214"

View File

@ -31,7 +31,7 @@ mime_guess = "2.0.4"
hyper = { version = "0.14.26", features = [ "server", "stream" ]} hyper = { version = "0.14.26", features = [ "server", "stream" ]}
http = "0.2.9" http = "0.2.9"
serde_urlencoded = "0.7.1" serde_urlencoded = "0.7.1"
tokio-util = "0.7.8" tokio-util = { version = "0.7.8", features = [ "io" ]}
acme2 = "0.5.1" acme2 = "0.5.1"
openssl = "0.10.52" openssl = "0.10.52"
rustls = "0.21.1" rustls = "0.21.1"
@ -45,6 +45,8 @@ serde_yaml = "0.9.22"
rand = "0.8.5" rand = "0.8.5"
reqwest = "0.11.18" reqwest = "0.11.18"
hyper-reverse-proxy = "0.5.1" hyper-reverse-proxy = "0.5.1"
gemini = "0.0.5"
bytes = "1.4.0"
[patch.crates-io] [patch.crates-io]
tokio-rustls = { git = "https://code.betamike.com/micropelago/tokio-rustls.git", branch = "start-handshake-into-inner" } tokio-rustls = { git = "https://code.betamike.com/micropelago/tokio-rustls.git", branch = "start-handshake-into-inner" }

View File

@ -7,6 +7,17 @@ their DNS server.
[Demo which may or may not be live](https://domani.mediocregopher.com) [Demo which may or may not be live](https://domani.mediocregopher.com)
Domani supports serving domains using the following protocols:
- HTTP
- HTTPS (with SSL certificates automatically retrieved using LetsEncrypt)
- [Gemini](https://gemini.circumlunar.space/)
Files are served as-is, with their extension being used to determine
Content-Type. If a directory is requested (e.g. `/some/dir/`) then `index.html`
will be requested if the protocol is HTTP, or `index.gmi` if the protocol is
gemini.
## Build ## Build
Domani uses nix flakes for building and setting up the development environment. Domani uses nix flakes for building and setting up the development environment.
@ -60,13 +71,13 @@ domain:
# An example built-in domain backed by a git repo. # An example built-in domain backed by a git repo.
#git.example.com: #git.example.com:
# kind: git #kind: git
# url: "https://somewhere.com/some/repo.git" #url: "https://somewhere.com/some/repo.git"
# branch_name: main #branch_name: main
#
# # If true then the built-in will be included in the web interface's # If true then the built-in will be included in the web interface's
# # domain list, but will not be configurable in the web interface # domain list, but will not be configurable in the web interface
# public: false #public: false
service: service:
@ -102,8 +113,9 @@ service:
# https_addr is set. # https_addr is set.
#http_addr: "[::]:3080" #http_addr: "[::]:3080"
# The address to listen for HTTPS requests on. This is optional. # The address to listen for HTTPS requests on. Defaults to not having HTTP
#https_addr: "[::]:443" # enabled. You can enable HTTPS by setting this to "[::]:443".
#https_addr: null
#proxied_domains: #proxied_domains:
@ -117,7 +129,6 @@ service:
# * dns.resolver_addr is ignored and the system-wide dns is used # * dns.resolver_addr is ignored and the system-wide dns is used
# #
#proxy.example.com: #proxy.example.com:
# kind: http_proxy
# url: "http://some.other.service.com" # url: "http://some.other.service.com"
# #
# # Extra headers to add to proxied requests # # Extra headers to add to proxied requests
@ -126,6 +137,25 @@ service:
# value: "yet.another.service.com" # value: "yet.another.service.com"
# - name: X-HEADER-TO-DELETE # - name: X-HEADER-TO-DELETE
# value: "" # value: ""
#gemini:
# The address to listen for gemini requests on. Set this to null to disable
# gemini support.
#gemini_addr: "[::]:3965"
#proxied_domains:
# An example built-in domain backed by a reverse-proxy to some other
# gemini server. Requests to this domain will have connections
# transparently proxied to the backing server.
#
# Proxies are currently limited in the following ways:
# * url must be to a gemini endpoint
# * dns.resolver_addr is ignored and the system-wide dns is used
#
#proxy.example.com:
# url: "gemini://some.other.service.com"
``` ```
The YAML config file can be passed to the Domani process via the `--config-path` The YAML config file can be passed to the Domani process via the `--config-path`
@ -159,10 +189,25 @@ nix develop
Within the shell which opens you can do `cargo run` to start a local instance. Within the shell which opens you can do `cargo run` to start a local instance.
Using the default configuration, the domain `domani-test.localhost` should be
immediately available at:
* `http://domani-test.localhost:3080`
* `gemini://domani-test.localhost:3965`
## Roadmap ## Roadmap
* Better web interface design.
* Tutorials aimed at beginner users.
* Support for more backends than just git repositories, including: * Support for more backends than just git repositories, including:
* IPFS/IPNS * IPFS/IPNS
* Small static files (e.g. for well-knowns) * Small static files (e.g. for well-knowns)
* Google Drive * Google Drive
* Dropbox * Dropbox
* Automatic HTTP/gemtext rendering for markdown files.
* Automatic HTTP rendering for gemtext files.
* Ability to disable the web interface.
* Ability to disable HTTP completely.

View File

@ -4,7 +4,21 @@ use crate::{domain, util};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{fs, path, sync}; use std::{fs, path};
pub trait Store {
fn get_certificate(
&self,
domain: &domain::Name,
) -> unexpected::Result<Option<(PrivateKey, Certificate)>>;
fn set_certificate(
&self,
domain: &domain::Name,
pkey: PrivateKey,
cert: Certificate,
) -> unexpected::Result<()>;
}
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
struct StoredPKeyCert { struct StoredPKeyCert {
@ -30,11 +44,13 @@ impl FSStore {
domain.push_str(".json"); domain.push_str(".json");
self.cert_dir_path.join(domain) self.cert_dir_path.join(domain)
} }
}
impl Store for FSStore {
fn get_certificate( fn get_certificate(
&self, &self,
domain: &domain::Name, domain: &domain::Name,
) -> unexpected::Result<(PrivateKey, Certificate)> { ) -> unexpected::Result<Option<(PrivateKey, Certificate)>> {
let path = self.pkey_cert_path(domain); let path = self.pkey_cert_path(domain);
let file = match util::open_file(path.as_path()) let file = match util::open_file(path.as_path())
@ -42,9 +58,23 @@ impl FSStore {
{ {
Some(file) => file, Some(file) => file,
None => { None => {
let pkey = PrivateKey::new(); return Ok(None);
let cert = Certificate::new_self_signed(&pkey, domain) }
.or_unexpected_while("creating self-signed cert")?; };
let stored: StoredPKeyCert =
serde_json::from_reader(file).or_unexpected_while("parsing json")?;
Ok(Some((stored.private_key, stored.cert)))
}
fn set_certificate(
&self,
domain: &domain::Name,
pkey: PrivateKey,
cert: Certificate,
) -> unexpected::Result<()> {
let path = self.pkey_cert_path(domain);
let file = fs::File::create(path.as_path()) let file = fs::File::create(path.as_path())
.map_unexpected_while(|| format!("creating file {}", path.display()))?; .map_unexpected_while(|| format!("creating file {}", path.display()))?;
@ -56,46 +86,6 @@ impl FSStore {
serde_json::to_writer(file, &stored).or_unexpected_while("writing cert to file")?; serde_json::to_writer(file, &stored).or_unexpected_while("writing cert to file")?;
return Ok((stored.private_key, stored.cert)); return Ok(());
}
};
let stored: StoredPKeyCert =
serde_json::from_reader(file).or_unexpected_while("parsing json")?;
Ok((stored.private_key, stored.cert))
}
}
impl rustls::server::ResolvesServerCert for FSStore {
fn resolve(
&self,
client_hello: rustls::server::ClientHello<'_>,
) -> Option<sync::Arc<rustls::sign::CertifiedKey>> {
let domain = client_hello.server_name()?;
let res: unexpected::Result<Option<sync::Arc<rustls::sign::CertifiedKey>>> = (|| {
let domain: domain::Name = domain
.parse()
.map_unexpected_while(|| format!("parsing domain {domain}"))?;
let (pkey, cert) = self
.get_certificate(&domain)
.or_unexpected_while("fetching pkey/cert")?;
let pkey = rustls::sign::any_supported_type(&pkey.into()).or_unexpected()?;
Ok(Some(sync::Arc::new(rustls::sign::CertifiedKey {
cert: vec![cert.into()],
key: pkey,
ocsp: None,
sct_list: None,
})))
})();
res.unwrap_or_else(|err| {
log::error!("Unexpected error getting cert for domain {domain}: {err}");
None
})
} }
} }

View File

@ -1,4 +1,4 @@
use crate::domain::{self, acme, checker, store}; use crate::domain::{self, acme, checker, gemini, store, tls};
use crate::error::unexpected::{self, Mappable}; use crate::error::unexpected::{self, Mappable};
use crate::{origin, task_stack, util}; use crate::{origin, task_stack, util};
@ -140,7 +140,7 @@ impl From<store::SetError> for SyncWithSettingsError {
pub type GetAcmeHttp01ChallengeKeyError = acme::manager::GetHttp01ChallengeKeyError; pub type GetAcmeHttp01ChallengeKeyError = acme::manager::GetHttp01ChallengeKeyError;
//#[mockall::automock] //#[mockall::automock]
pub trait Manager: Sync + Send + rustls::server::ResolvesServerCert { pub trait Manager: Sync + Send {
fn get_settings(&self, domain: &domain::Name) -> Result<GetSettingsResult, GetSettingsError>; fn get_settings(&self, domain: &domain::Name) -> Result<GetSettingsResult, GetSettingsError>;
fn get_file<'store>( fn get_file<'store>(
@ -178,6 +178,7 @@ pub struct ManagerImpl {
domain_store: Box<dyn store::Store + Send + Sync>, domain_store: Box<dyn store::Store + Send + Sync>,
domain_checker: checker::DNSChecker, domain_checker: checker::DNSChecker,
acme_manager: Option<Box<dyn acme::manager::Manager + Send + Sync>>, acme_manager: Option<Box<dyn acme::manager::Manager + Send + Sync>>,
gemini_store: Option<Box<dyn gemini::Store + Send + Sync>>,
} }
impl ManagerImpl { impl ManagerImpl {
@ -185,12 +186,14 @@ impl ManagerImpl {
OriginStore: origin::Store + Send + Sync + 'static, OriginStore: origin::Store + Send + Sync + 'static,
DomainStore: store::Store + Send + Sync + 'static, DomainStore: store::Store + Send + Sync + 'static,
AcmeManager: acme::manager::Manager + Send + Sync + 'static, AcmeManager: acme::manager::Manager + Send + Sync + 'static,
GeminiStore: gemini::Store + Send + Sync + 'static,
>( >(
task_stack: &mut task_stack::TaskStack<unexpected::Error>, task_stack: &mut task_stack::TaskStack<unexpected::Error>,
origin_store: OriginStore, origin_store: OriginStore,
domain_store: DomainStore, domain_store: DomainStore,
domain_checker: checker::DNSChecker, domain_checker: checker::DNSChecker,
acme_manager: Option<AcmeManager>, acme_manager: Option<AcmeManager>,
gemini_store: Option<GeminiStore>,
) -> sync::Arc<Self> { ) -> sync::Arc<Self> {
let manager = sync::Arc::new(ManagerImpl { let manager = sync::Arc::new(ManagerImpl {
origin_store: Box::from(origin_store), origin_store: Box::from(origin_store),
@ -198,6 +201,7 @@ impl ManagerImpl {
domain_checker, domain_checker,
acme_manager: acme_manager acme_manager: acme_manager
.map(|m| Box::new(m) as Box<dyn acme::manager::Manager + Send + Sync>), .map(|m| Box::new(m) as Box<dyn acme::manager::Manager + Send + Sync>),
gemini_store: gemini_store.map(|m| Box::new(m) as Box<dyn gemini::Store + Send + Sync>),
}); });
task_stack.push_spawn(|canceller| { task_stack.push_spawn(|canceller| {
@ -248,6 +252,23 @@ impl ManagerImpl {
} }
} }
} }
fn sync_gemini_cert(&self, domain: &domain::Name) -> unexpected::Result<()> {
if let Some(ref gemini_store) = self.gemini_store {
if let Some(_) = gemini_store.get_certificate(domain).or_unexpected()? {
return Ok(());
}
// no cert/key stored for the domain, generate and store it
let pkey = tls::PrivateKey::new();
let cert = tls::Certificate::new_self_signed(&pkey, domain)
.or_unexpected_while("creating self-signed cert")?;
gemini_store.set_certificate(domain, pkey, cert)?;
}
Ok(())
}
} }
impl Manager for ManagerImpl { impl Manager for ManagerImpl {
@ -300,6 +321,8 @@ impl Manager for ManagerImpl {
self.domain_store.set(&domain, &settings)?; self.domain_store.set(&domain, &settings)?;
self.sync_gemini_cert(&domain)?;
self.sync_https_cert(domain).await?; self.sync_https_cert(domain).await?;
Ok(()) Ok(())
@ -329,14 +352,22 @@ impl Manager for ManagerImpl {
} }
} }
impl rustls::server::ResolvesServerCert for ManagerImpl { pub struct HttpsCertResolver(sync::Arc<ManagerImpl>);
impl From<sync::Arc<ManagerImpl>> for HttpsCertResolver {
fn from(mgr: sync::Arc<ManagerImpl>) -> Self {
Self(mgr)
}
}
impl rustls::server::ResolvesServerCert for HttpsCertResolver {
fn resolve( fn resolve(
&self, &self,
client_hello: rustls::server::ClientHello<'_>, client_hello: rustls::server::ClientHello<'_>,
) -> Option<sync::Arc<rustls::sign::CertifiedKey>> { ) -> Option<sync::Arc<rustls::sign::CertifiedKey>> {
let domain = client_hello.server_name()?; let domain = client_hello.server_name()?;
match self.acme_manager.as_ref()?.get_certificate(domain) { match (self.0).acme_manager.as_ref()?.get_certificate(domain) {
Err(acme::manager::GetCertificateError::NotFound) => { Err(acme::manager::GetCertificateError::NotFound) => {
log::warn!("No cert found for domain {domain}"); log::warn!("No cert found for domain {domain}");
Ok(None) Ok(None)
@ -360,3 +391,53 @@ impl rustls::server::ResolvesServerCert for ManagerImpl {
}) })
} }
} }
pub struct GeminiCertResolver(sync::Arc<ManagerImpl>);
impl From<sync::Arc<ManagerImpl>> for GeminiCertResolver {
fn from(mgr: sync::Arc<ManagerImpl>) -> Self {
Self(mgr)
}
}
impl rustls::server::ResolvesServerCert for GeminiCertResolver {
fn resolve(
&self,
client_hello: rustls::server::ClientHello<'_>,
) -> Option<sync::Arc<rustls::sign::CertifiedKey>> {
let domain = client_hello.server_name()?;
let domain: domain::Name = match domain.parse() {
Ok(domain) => domain,
Err(e) => {
log::warn!("failed to parse domain name {domain}: {e}");
return None;
}
};
let res: unexpected::Result<Option<sync::Arc<rustls::sign::CertifiedKey>>> = (|| {
(self.0)
.gemini_store
.as_ref()
.or_unexpected_while("gemini store is not enabled")?
.get_certificate(&domain)
.or_unexpected_while("fetching pkey/cert")?
.map(|(pkey, cert)| {
let pkey = rustls::sign::any_supported_type(&pkey.into()).or_unexpected()?;
Ok(sync::Arc::new(rustls::sign::CertifiedKey {
cert: vec![cert.into()],
key: pkey,
ocsp: None,
sct_list: None,
}))
})
.transpose()
})();
res.unwrap_or_else(|err| {
log::error!("Unexpected error getting cert for domain {domain}: {err}");
None
})
}
}

View File

@ -4,7 +4,7 @@ use clap::Parser;
use futures::stream::StreamExt; use futures::stream::StreamExt;
use signal_hook_tokio::Signals; use signal_hook_tokio::Signals;
use std::{path, sync}; use std::path;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(version)] #[command(version)]
@ -87,6 +87,8 @@ async fn main() {
return; return;
}; };
let gemini_enabled = config.service.gemini.gemini_addr.is_some();
let origin_store = domani::origin::git::FSStore::new(&config.origin) let origin_store = domani::origin::git::FSStore::new(&config.origin)
.expect("git origin store initialization failed"); .expect("git origin store initialization failed");
@ -128,9 +130,14 @@ async fn main() {
None None
}; };
let domain_gemini_store = let domain_gemini_store = if gemini_enabled {
Some(
domani::domain::gemini::FSStore::new(&config.domain.store_dir_path.join("gemini")) domani::domain::gemini::FSStore::new(&config.domain.store_dir_path.join("gemini"))
.unwrap_or_else(|e| panic!("domain gemini store initialization failed: {e}")); .unwrap_or_else(|e| panic!("domain gemini store initialization failed: {e}")),
)
} else {
None
};
let mut task_stack = domani::task_stack::TaskStack::new(); let mut task_stack = domani::task_stack::TaskStack::new();
@ -140,20 +147,24 @@ async fn main() {
domain_store, domain_store,
domain_checker, domain_checker,
domain_acme_manager, domain_acme_manager,
domain_gemini_store,
); );
let _ = domani::service::http::new( let _ = domani::service::http::Service::new(
&mut task_stack, &mut task_stack,
domain_manager.clone(), domain_manager.clone(),
domain_manager.clone(), domani::domain::manager::HttpsCertResolver::from(domain_manager.clone()),
config.service.clone(), config.service.clone(),
); );
if gemini_enabled {
let _ = domani::service::gemini::Service::new( let _ = domani::service::gemini::Service::new(
&mut task_stack, &mut task_stack,
sync::Arc::new(domain_gemini_store), domain_manager.clone(),
domani::domain::manager::GeminiCertResolver::from(domain_manager.clone()),
config.service, config.service,
); );
}
let mut signals = let mut signals =
Signals::new(signal_hook::consts::TERM_SIGNALS).expect("initializing signals failed"); Signals::new(signal_hook::consts::TERM_SIGNALS).expect("initializing signals failed");

View File

@ -332,7 +332,7 @@ impl super::Store for FSStore {
// TODO this is very not ideal, the whole file is first read totally into memory, and then // TODO this is very not ideal, the whole file is first read totally into memory, and then
// that is cloned. // that is cloned.
let data = file_object.data.clone(); let data = bytes::Bytes::copy_from_slice(file_object.data.as_slice());
Ok(Box::pin(stream::once(async move { Ok(data) }))) Ok(Box::pin(stream::once(async move { Ok(data) })))
} }
} }

View File

@ -3,3 +3,32 @@ pub mod gemini;
pub mod http; pub mod http;
pub use config::*; pub use config::*;
use std::borrow;
fn append_index_to_path<'path, 'index>(
path: &'path str,
index: &'index str,
) -> borrow::Cow<'path, str> {
if path.len() == 0 {
let mut path = String::with_capacity(1 + index.len());
path.push('/');
path.push_str(index);
return borrow::Cow::Owned(path);
}
if path.ends_with('/') {
let mut indexed_path = String::with_capacity(path.len() + index.len());
indexed_path.push_str(path.as_ref());
indexed_path.push_str(index);
return borrow::Cow::Owned(indexed_path);
}
borrow::Cow::Borrowed(path)
}
fn guess_mime(path: &str) -> String {
mime_guess::from_path(path)
.first_or_octet_stream()
.to_string()
}

View File

@ -4,12 +4,13 @@ mod proxy;
pub use config::*; pub use config::*;
use crate::error::unexpected::{self, Mappable}; use crate::error::unexpected::{self, Mappable};
use crate::{domain, service, task_stack}; use crate::{domain, service, task_stack, util};
use std::sync; use std::sync;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
pub struct Service { pub struct Service {
domain_manager: sync::Arc<dyn domain::manager::Manager>,
cert_resolver: sync::Arc<dyn rustls::server::ResolvesServerCert>, cert_resolver: sync::Arc<dyn rustls::server::ResolvesServerCert>,
config: service::Config, config: service::Config,
} }
@ -24,19 +25,92 @@ enum HandleConnError {
} }
impl Service { impl Service {
pub fn new( pub fn new<CertResolver>(
task_stack: &mut task_stack::TaskStack<unexpected::Error>, task_stack: &mut task_stack::TaskStack<unexpected::Error>,
cert_resolver: sync::Arc<dyn rustls::server::ResolvesServerCert>, domain_manager: sync::Arc<dyn domain::manager::Manager>,
cert_resolver: CertResolver,
config: service::Config, config: service::Config,
) -> sync::Arc<Service> { ) -> sync::Arc<Service>
where
CertResolver: rustls::server::ResolvesServerCert + 'static,
{
let service = sync::Arc::new(Service { let service = sync::Arc::new(Service {
cert_resolver, domain_manager,
cert_resolver: sync::Arc::from(cert_resolver),
config, config,
}); });
task_stack.push_spawn(|canceller| listen(service.clone(), canceller)); task_stack.push_spawn(|canceller| listen(service.clone(), canceller));
service service
} }
async fn respond_conn<W>(
&self,
w: W,
code: &str,
meta: &str,
body: Option<util::BoxByteStream>,
) -> unexpected::Result<()>
where
W: tokio::io::AsyncWrite + Unpin,
{
use tokio::io::{copy, AsyncWriteExt, BufWriter};
let mut w = BufWriter::new(w);
w.write_all(code.as_bytes()).await.or_unexpected()?;
w.write_all(" ".as_bytes()).await.or_unexpected()?;
w.write_all(meta.as_bytes()).await.or_unexpected()?;
w.write_all("\r\n".as_bytes()).await.or_unexpected()?;
if let Some(body) = body {
let mut body = tokio_util::io::StreamReader::new(body);
copy(&mut body, &mut w).await.or_unexpected()?;
}
w.flush().await.or_unexpected()?;
Ok(())
}
async fn serve_conn<IO>(&self, domain: &domain::Name, conn: IO) -> Result<(), HandleConnError>
where
IO: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin,
{
use tokio::io::*;
let (r, w) = split(conn);
let mut r = BufReader::new(r);
let mut req = String::with_capacity(64);
r.read_line(&mut req)
.await
.map_err(|e| HandleConnError::ClientError(format!("failed to read request: {e}")))?;
let req = gemini::request::parse::request(req.as_bytes())
.map(|(_, req)| req)
.map_err(|e| HandleConnError::ClientError(format!("failed to parse request: {e}")))?
.into_gemini_request()
.map_err(|e| HandleConnError::ClientError(format!("failed to parse request: {e}")))?;
let path = service::append_index_to_path(req.path(), "index.gmi");
let f = match self.domain_manager.get_file(domain, &path) {
Ok(f) => f,
Err(domain::manager::GetFileError::DomainNotFound) => {
return Err(unexpected::Error::from("domain not found when serving file").into())
}
Err(domain::manager::GetFileError::FileNotFound) => {
return Ok(self.respond_conn(w, "51", "File not found", None).await?)
}
Err(domain::manager::GetFileError::Unexpected(e)) => return Err(e.into()),
};
let content_type = service::guess_mime(&path);
Ok(self
.respond_conn(w, "20", content_type.as_str(), Some(f))
.await?)
}
async fn proxy_conn<IO>( async fn proxy_conn<IO>(
&self, &self,
proxied_domain: &ConfigProxiedDomain, proxied_domain: &ConfigProxiedDomain,
@ -59,7 +133,7 @@ impl Service {
async fn handle_conn( async fn handle_conn(
&self, &self,
conn: tokio::net::TcpStream, conn: tokio::net::TcpStream,
_tls_config: sync::Arc<rustls::ServerConfig>, tls_config: sync::Arc<rustls::ServerConfig>,
) -> Result<(), HandleConnError> { ) -> Result<(), HandleConnError> {
let teed_conn = { let teed_conn = {
let (r, w) = tokio::io::split(conn); let (r, w) = tokio::io::split(conn);
@ -92,9 +166,8 @@ impl Service {
return Ok(()); return Ok(());
} }
return Err(HandleConnError::ClientError(format!( let conn = start.into_stream(tls_config).await.or_unexpected()?;
"unknown domain {domain}" self.serve_conn(&domain, conn).await
)));
} }
Err(err) => { Err(err) => {
return Err(unexpected::Error::from( return Err(unexpected::Error::from(
@ -110,20 +183,26 @@ async fn listen(
service: sync::Arc<Service>, service: sync::Arc<Service>,
canceller: CancellationToken, canceller: CancellationToken,
) -> unexpected::Result<()> { ) -> unexpected::Result<()> {
let addr = &service
.config
.gemini
.gemini_addr
.expect("listen called with gemini_addr not set");
let tls_config = sync::Arc::new( let tls_config = sync::Arc::new(
rustls::server::ServerConfig::builder() rustls::server::ServerConfig::builder()
.with_safe_defaults() .with_safe_defaults()
.with_no_client_auth() // TODO maybe this isn't right? .with_no_client_auth()
.with_cert_resolver(service.cert_resolver.clone()), .with_cert_resolver(service.cert_resolver.clone()),
); );
log::info!( log::info!(
"Listening on gemini://{}:{}", "Listening on gemini://{}:{}",
&service.config.primary_domain.clone(), &service.config.primary_domain.clone(),
&service.config.gemini.gemini_addr.port(), addr,
); );
let listener = tokio::net::TcpListener::bind(service.config.gemini.gemini_addr) let listener = tokio::net::TcpListener::bind(addr)
.await .await
.or_unexpected_while("binding tcp socket")?; .or_unexpected_while("binding tcp socket")?;

View File

@ -6,8 +6,8 @@ use serde_with::{serde_as, TryFromInto};
use std::{collections, net, str::FromStr}; use std::{collections, net, str::FromStr};
fn default_gemini_addr() -> net::SocketAddr { fn default_gemini_addr() -> Option<net::SocketAddr> {
net::SocketAddr::from_str("[::]:3965").unwrap() Some(net::SocketAddr::from_str("[::]:3965").unwrap())
} }
#[derive(Deserialize, Serialize, Clone)] #[derive(Deserialize, Serialize, Clone)]
@ -65,7 +65,7 @@ pub struct ConfigProxiedDomain {
#[derive(Deserialize, Serialize, Clone)] #[derive(Deserialize, Serialize, Clone)]
pub struct Config { pub struct Config {
#[serde(default = "default_gemini_addr")] #[serde(default = "default_gemini_addr")]
pub gemini_addr: net::SocketAddr, pub gemini_addr: Option<net::SocketAddr>,
pub proxied_domains: collections::HashMap<domain::Name, ConfigProxiedDomain>, pub proxied_domains: collections::HashMap<domain::Name, ConfigProxiedDomain>,
} }

View File

@ -23,31 +23,6 @@ pub struct Service {
config: service::Config, config: service::Config,
} }
pub fn new(
task_stack: &mut task_stack::TaskStack<unexpected::Error>,
domain_manager: sync::Arc<dyn domain::manager::Manager>,
cert_resolver: sync::Arc<dyn rustls::server::ResolvesServerCert>,
config: service::Config,
) -> sync::Arc<Service> {
let https_enabled = config.http.https_addr.is_some();
let service = sync::Arc::new(Service {
domain_manager: domain_manager.clone(),
cert_resolver,
handlebars: tpl::get(),
config,
});
task_stack.push_spawn(|canceller| tasks::listen_http(service.clone(), canceller));
if https_enabled {
task_stack.push_spawn(|canceller| tasks::listen_https(service.clone(), canceller));
task_stack.push_spawn(|canceller| tasks::cert_refresher(service.clone(), canceller));
}
service
}
#[derive(Serialize)] #[derive(Serialize)]
struct BasePresenter<'a, T> { struct BasePresenter<'a, T> {
page_name: &'a str, page_name: &'a str,
@ -77,15 +52,39 @@ struct DomainSyncArgs {
url_encoded_domain_settings: util::UrlEncodedDomainSettings, url_encoded_domain_settings: util::UrlEncodedDomainSettings,
} }
impl<'svc> Service { impl Service {
fn serve(&self, status_code: u16, path: &str, body: Body) -> Response<Body> { pub fn new<CertResolver>(
let content_type = mime_guess::from_path(path) task_stack: &mut task_stack::TaskStack<unexpected::Error>,
.first_or_octet_stream() domain_manager: sync::Arc<dyn domain::manager::Manager>,
.to_string(); cert_resolver: CertResolver,
config: service::Config,
) -> sync::Arc<Service>
where
CertResolver: rustls::server::ResolvesServerCert + 'static,
{
let https_enabled = config.http.https_addr.is_some();
let service = sync::Arc::new(Service {
domain_manager: domain_manager.clone(),
cert_resolver: sync::Arc::from(cert_resolver),
handlebars: tpl::get(),
config,
});
task_stack.push_spawn(|canceller| tasks::listen_http(service.clone(), canceller));
if https_enabled {
task_stack.push_spawn(|canceller| tasks::listen_https(service.clone(), canceller));
task_stack.push_spawn(|canceller| tasks::cert_refresher(service.clone(), canceller));
}
service
}
fn serve(&self, status_code: u16, path: &str, body: Body) -> Response<Body> {
match Response::builder() match Response::builder()
.status(status_code) .status(status_code)
.header("Content-Type", content_type) .header("Content-Type", service::guess_mime(path))
.body(body) .body(body)
{ {
Ok(res) => res, Ok(res) => res,
@ -161,20 +160,10 @@ impl<'svc> Service {
} }
async fn serve_origin(&self, domain: domain::Name, req: Request<Body>) -> Response<Body> { async fn serve_origin(&self, domain: domain::Name, req: Request<Body>) -> Response<Body> {
let mut path_owned; let path = service::append_index_to_path(req.uri().path(), "index.html");
let path = req.uri().path();
let path = match path.ends_with('/') { match self.domain_manager.get_file(&domain, &path) {
true => { Ok(f) => self.serve(200, &path, Body::wrap_stream(f)),
path_owned = String::from(path);
path_owned.push_str("index.html");
path_owned.as_str()
}
false => path,
};
match self.domain_manager.get_file(&domain, path) {
Ok(f) => self.serve(200, path, Body::wrap_stream(f)),
Err(domain::manager::GetFileError::DomainNotFound) => { Err(domain::manager::GetFileError::DomainNotFound) => {
return self.render_error_page(404, "Domain not found") return self.render_error_page(404, "Domain not found")
} }

View File

@ -10,6 +10,6 @@ pub fn open_file(path: &path::Path) -> io::Result<Option<fs::File>> {
} }
} }
pub type BoxByteStream = futures::stream::BoxStream<'static, io::Result<Vec<u8>>>; pub type BoxByteStream = futures::stream::BoxStream<'static, io::Result<bytes::Bytes>>;
pub type BoxFuture<'a, O> = pin::Pin<Box<dyn futures::Future<Output = O> + Send + 'a>>; pub type BoxFuture<'a, O> = pin::Pin<Box<dyn futures::Future<Output = O> + Send + 'a>>;