Compare commits

..

7 Commits

Author SHA1 Message Date
Brian Picciano
ceb2ba3cf4 Move dns records under service in the config 2023-07-09 16:57:36 +02:00
Brian Picciano
57b56934a9 Switch to using a config file 2023-07-09 16:09:00 +02:00
Brian Picciano
1bc3420930 remove github run dir 2023-07-09 15:34:58 +02:00
Brian Picciano
7c68702ab8 Introduce domain::Config 2023-07-09 15:09:40 +02:00
Brian Picciano
80e96c47fb Rename domain::Config to domain::Domain, plus other moving 2023-07-09 14:25:01 +02:00
Brian Picciano
9c1bdc1e8a Introduce origin config 2023-07-09 14:07:07 +02:00
Brian Picciano
254d9c63d0 Config struct for http server 2023-07-09 13:43:38 +02:00
23 changed files with 604 additions and 469 deletions

9
.dev-config.yml Normal file
View File

@ -0,0 +1,9 @@
origin:
store_dir_path: /tmp/domani_dev_env/origin
domain:
store_dir_path: /tmp/domani_dev_env/domain
service:
passphrase: foobar
dns_records:
- type: A
addr: 127.0.0.1

View File

@ -1,6 +1 @@
export DOMANI_HTTP_DOMAIN=localhost export DOMANI_CONFIG_PATH=./.dev-config.yml
export DOMANI_PASSPHRASE=foobar
export DOMANI_ORIGIN_STORE_GIT_DIR_PATH=/tmp/domani_dev_env/origin/git
export DOMANI_DOMAIN_CHECKER_TARGET_A=127.0.0.1
export DOMANI_DOMAIN_CONFIG_STORE_DIR_PATH=/tmp/domani_dev_env/domain/config
export DOMANI_DOMAIN_ACME_STORE_DIR_PATH=/tmp/domani_dev_env/domain/acme

View File

@ -1,13 +0,0 @@
name: "Build legacy Nix package on Ubuntu"
on:
push:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: cachix/install-nix-action@v12
- name: Building package
run: nix-build . -A defaultPackage.x86_64-linux

46
Cargo.lock generated
View File

@ -467,6 +467,7 @@ dependencies = [
"serde_json", "serde_json",
"serde_urlencoded", "serde_urlencoded",
"serde_with", "serde_with",
"serde_yaml",
"sha2", "sha2",
"signal-hook", "signal-hook",
"signal-hook-tokio", "signal-hook-tokio",
@ -537,6 +538,12 @@ dependencies = [
"termcolor", "termcolor",
] ]
[[package]]
name = "equivalent"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88bffebc5d80432c9b140ee17875ff173a8ab62faad5b257da912bd2f6c1c0a1"
[[package]] [[package]]
name = "errno" name = "errno"
version = "0.3.1" version = "0.3.1"
@ -1362,7 +1369,7 @@ dependencies = [
"futures-sink", "futures-sink",
"futures-util", "futures-util",
"http", "http",
"indexmap", "indexmap 1.9.3",
"slab", "slab",
"tokio", "tokio",
"tokio-util", "tokio-util",
@ -1396,6 +1403,12 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
[[package]]
name = "hashbrown"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c6201b9ff9fd90a5a3bac2e56a830d0caa509576f0e503818ee82c181b3437a"
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.4.1" version = "0.4.1"
@ -1597,6 +1610,16 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "indexmap"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5477fe2230a79769d8dc68e0eabf5437907c0457a5614a9e8dddb67f65eb65d"
dependencies = [
"equivalent",
"hashbrown 0.14.0",
]
[[package]] [[package]]
name = "instant" name = "instant"
version = "0.1.12" version = "0.1.12"
@ -2552,7 +2575,7 @@ dependencies = [
"base64 0.21.0", "base64 0.21.0",
"chrono", "chrono",
"hex", "hex",
"indexmap", "indexmap 1.9.3",
"serde", "serde",
"serde_json", "serde_json",
"serde_with_macros", "serde_with_macros",
@ -2571,6 +2594,19 @@ dependencies = [
"syn 2.0.15", "syn 2.0.15",
] ]
[[package]]
name = "serde_yaml"
version = "0.9.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "452e67b9c20c37fa79df53201dc03839651086ed9bbe92b3ca585ca9fdaa7d85"
dependencies = [
"indexmap 2.0.0",
"itoa",
"ryu",
"serde",
"unsafe-libyaml",
]
[[package]] [[package]]
name = "sha1_smol" name = "sha1_smol"
version = "1.0.0" version = "1.0.0"
@ -3041,6 +3077,12 @@ dependencies = [
"tinyvec", "tinyvec",
] ]
[[package]]
name = "unsafe-libyaml"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1865806a559042e51ab5414598446a5871b561d21b6764f2eabb0dd481d880a6"
[[package]] [[package]]
name = "untrusted" name = "untrusted"
version = "0.7.1" version = "0.7.1"

View File

@ -41,3 +41,4 @@ tls-listener = { version = "0.7.0", features = [ "rustls", "hyper-h1" ]}
tokio-rustls = "0.24.0" tokio-rustls = "0.24.0"
log = "0.4.19" log = "0.4.19"
env_logger = "0.10.0" env_logger = "0.10.0"
serde_yaml = "0.9.22"

8
src/config.rs Normal file
View File

@ -0,0 +1,8 @@
use serde::Deserialize;
#[derive(Deserialize)]
pub struct Config {
pub origin: crate::origin::Config,
pub domain: crate::domain::Config,
pub service: crate::service::Config,
}

View File

@ -1,80 +1,31 @@
pub mod acme; pub mod acme;
pub mod checker; pub mod checker;
pub mod config; mod config;
pub mod manager; pub mod manager;
mod name;
pub mod store;
use std::fmt; pub use config::*;
use std::str::FromStr; pub use name::*;
use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use crate::error::unexpected::{self, Mappable};
use trust_dns_client::rr as trust_dns_rr; use crate::origin;
#[derive(Debug, Clone)] use hex::ToHex;
/// Validated representation of a domain name use serde::{Deserialize, Serialize};
pub struct Name { use sha2::{Digest, Sha256};
inner: trust_dns_rr::Name,
utf8_str: String, #[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
/// Defines how a domain will behave when it is accessed. These are configured by the owner of the
/// domain during setup.
pub struct Domain {
pub origin_descr: origin::Descr,
} }
impl Name { impl Domain {
pub fn as_str(&self) -> &str { pub fn hash(&self) -> Result<String, unexpected::Error> {
self.utf8_str.as_str() let mut h = Sha256::new();
} serde_json::to_writer(&mut h, self).or_unexpected()?;
} Ok(h.finalize().encode_hex::<String>())
impl fmt::Display for Name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.utf8_str)
}
}
impl FromStr for Name {
type Err = <trust_dns_rr::Name as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut n = trust_dns_rr::Name::from_str(s)?;
let utf8_str = n.clone().to_utf8();
n.set_fqdn(true);
Ok(Name { inner: n, utf8_str })
}
}
impl Serialize for Name {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
struct NameVisitor;
impl<'de> de::Visitor<'de> for NameVisitor {
type Value = Name;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "a valid domain name")
}
fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
match Name::from_str(s) {
Ok(n) => Ok(n),
Err(e) => Err(E::custom(format!("invalid domain name: {}", e))),
}
}
}
impl<'de> Deserialize<'de> for Name {
fn deserialize<D>(deserializer: D) -> Result<Name, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_str(NameVisitor)
} }
} }

View File

@ -33,7 +33,7 @@ pub struct ManagerImpl {
impl ManagerImpl { impl ManagerImpl {
pub async fn new<Store: acme::store::Store + Send + Sync + 'static>( pub async fn new<Store: acme::store::Store + Send + Sync + 'static>(
store: Store, store: Store,
contact_email: &str, config: &domain::ConfigACME,
) -> Result<Self, unexpected::Error> { ) -> Result<Self, unexpected::Error> {
let dir = acme2::DirectoryBuilder::new(LETS_ENCRYPT_URL.to_string()) let dir = acme2::DirectoryBuilder::new(LETS_ENCRYPT_URL.to_string())
.build() .build()
@ -41,7 +41,7 @@ impl ManagerImpl {
.or_unexpected_while("creating acme2 directory builder")?; .or_unexpected_while("creating acme2 directory builder")?;
let mut contact = String::from("mailto:"); let mut contact = String::from("mailto:");
contact.push_str(contact_email); contact.push_str(config.contact_email.as_str());
let mut builder = acme2::AccountBuilder::new(dir); let mut builder = acme2::AccountBuilder::new(dir);
builder.contact(vec![contact]); builder.contact(vec![contact]);

View File

@ -1,4 +1,5 @@
use std::net; use std::net;
use std::ops::DerefMut;
use std::str::FromStr; use std::str::FromStr;
use crate::domain; use crate::domain;
@ -8,19 +9,10 @@ use trust_dns_client::client::{AsyncClient, ClientHandle};
use trust_dns_client::rr::{DNSClass, Name, RData, RecordType}; use trust_dns_client::rr::{DNSClass, Name, RData, RecordType};
use trust_dns_client::udp; use trust_dns_client::udp;
#[derive(thiserror::Error, Debug)]
pub enum NewDNSCheckerError {
#[error("invalid resolver address")]
InvalidResolverAddress,
#[error(transparent)]
Unexpected(#[from] unexpected::Error),
}
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum CheckDomainError { pub enum CheckDomainError {
#[error("target A not set")] #[error("no service dns records set")]
TargetANotSet, ServiceDNSRecordsNotSet,
#[error("challenge token not set")] #[error("challenge token not set")]
ChallengeTokenNotSet, ChallengeTokenNotSet,
@ -29,31 +21,65 @@ pub enum CheckDomainError {
Unexpected(#[from] unexpected::Error), Unexpected(#[from] unexpected::Error),
} }
pub struct DNSChecker { pub enum DNSRecord {
target_a: net::Ipv4Addr, A(net::Ipv4Addr),
}
impl DNSRecord {
async fn check_a(
client: &mut AsyncClient,
domain: &trust_dns_client::rr::Name,
addr: &net::Ipv4Addr,
) -> Result<bool, unexpected::Error> {
let response = client
.query(domain.clone(), DNSClass::IN, RecordType::A)
.await
.or_unexpected_while("querying A record")?;
let records = response.answers();
if records.len() != 1 {
return Ok(false);
}
// if the single record isn't a A, or it's not the target A, then return
// TargetANAMENotSet
match records[0].data() {
Some(RData::A(remote_a)) if remote_a == addr => Ok(true),
_ => return Ok(false),
}
}
async fn check(
&self,
client: &mut AsyncClient,
domain: &trust_dns_client::rr::Name,
) -> Result<bool, unexpected::Error> {
match self {
Self::A(addr) => Self::check_a(client, domain, &addr).await,
}
}
}
pub struct DNSChecker {
// TODO we should use some kind of connection pool here, I suppose // TODO we should use some kind of connection pool here, I suppose
client: tokio::sync::Mutex<AsyncClient>, client: tokio::sync::Mutex<AsyncClient>,
service_dns_records: Vec<DNSRecord>,
} }
impl DNSChecker { impl DNSChecker {
pub async fn new( pub async fn new(
target_a: net::Ipv4Addr, config: &domain::ConfigDNS,
resolver_addr: &str, service_dns_records: Vec<DNSRecord>,
) -> Result<Self, NewDNSCheckerError> { ) -> Result<Self, unexpected::Error> {
let resolver_addr = resolver_addr let stream = udp::UdpClientStream::<tokio::net::UdpSocket>::new(config.resolver_addr);
.parse()
.map_err(|_| NewDNSCheckerError::InvalidResolverAddress)?;
let stream = udp::UdpClientStream::<tokio::net::UdpSocket>::new(resolver_addr);
let (client, bg) = AsyncClient::connect(stream).await.or_unexpected()?; let (client, bg) = AsyncClient::connect(stream).await.or_unexpected()?;
tokio::spawn(bg); tokio::spawn(bg);
// TODO there should be a mechanism to clean this up
Ok(Self { Ok(Self {
target_a,
client: tokio::sync::Mutex::new(client), client: tokio::sync::Mutex::new(client),
service_dns_records,
}) })
} }
@ -62,31 +88,7 @@ impl DNSChecker {
domain: &domain::Name, domain: &domain::Name,
challenge_token: &str, challenge_token: &str,
) -> Result<(), CheckDomainError> { ) -> Result<(), CheckDomainError> {
let domain = &domain.inner; let domain = domain.as_rr();
// check that the A is installed correctly on the domain
{
let response = self
.client
.lock()
.await
.query(domain.clone(), DNSClass::IN, RecordType::A)
.await
.or_unexpected_while("querying A record")?;
let records = response.answers();
if records.len() != 1 {
return Err(CheckDomainError::TargetANotSet);
}
// if the single record isn't a A, or it's not the target A, then return
// TargetANAMENotSet
match records[0].data() {
Some(RData::A(remote_a)) if remote_a == &self.target_a => (),
_ => return Err(CheckDomainError::TargetANotSet),
}
}
// check that the TXT record with the challenge token is correctly installed on the domain // check that the TXT record with the challenge token is correctly installed on the domain
{ {
@ -115,6 +117,16 @@ impl DNSChecker {
} }
} }
Ok(()) // check that one of the possible DNS records is installed on the domain
for record in &self.service_dns_records {
let mut client = self.client.lock().await;
match record.check(client.deref_mut(), domain).await {
Ok(true) => return Ok(()),
Ok(false) => (),
Err(e) => return Err(e.into()),
}
}
Err(CheckDomainError::ServiceDNSRecordsNotSet)
} }
} }

View File

@ -1,160 +1,34 @@
use std::path::{Path, PathBuf}; use std::{net, path, str::FromStr};
use std::str::FromStr;
use std::{fs, io};
use crate::error::unexpected::{self, Intoable, Mappable}; use serde::Deserialize;
use crate::{domain, origin};
use hex::ToHex; fn default_resolver_addr() -> net::SocketAddr {
use serde::{Deserialize, Serialize}; net::SocketAddr::from_str("1.1.1.1:53").unwrap()
use sha2::{Digest, Sha256}; }
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] #[derive(Deserialize)]
/// Values which the owner of a domain can configure when they install a domain. pub struct ConfigDNS {
#[serde(default = "default_resolver_addr")]
pub resolver_addr: net::SocketAddr,
}
impl Default for ConfigDNS {
fn default() -> Self {
Self {
resolver_addr: default_resolver_addr(),
}
}
}
#[derive(Deserialize)]
pub struct ConfigACME {
pub contact_email: String,
}
#[derive(Deserialize)]
pub struct Config { pub struct Config {
pub origin_descr: origin::Descr, pub store_dir_path: path::PathBuf,
} #[serde(default)]
pub dns: ConfigDNS,
impl Config { pub acme: Option<ConfigACME>,
pub fn hash(&self) -> Result<String, unexpected::Error> {
let mut h = Sha256::new();
serde_json::to_writer(&mut h, self).or_unexpected()?;
Ok(h.finalize().encode_hex::<String>())
}
}
#[derive(thiserror::Error, Debug)]
pub enum GetError {
#[error("not found")]
NotFound,
#[error(transparent)]
Unexpected(#[from] unexpected::Error),
}
#[derive(thiserror::Error, Debug)]
pub enum SetError {
#[error(transparent)]
Unexpected(#[from] unexpected::Error),
}
#[mockall::automock]
pub trait Store {
fn get(&self, domain: &domain::Name) -> Result<Config, GetError>;
fn set(&self, domain: &domain::Name, config: &Config) -> Result<(), SetError>;
fn all_domains(&self) -> Result<Vec<domain::Name>, unexpected::Error>;
}
pub struct FSStore {
dir_path: PathBuf,
}
impl FSStore {
pub fn new(dir_path: &Path) -> io::Result<Self> {
fs::create_dir_all(dir_path)?;
Ok(Self {
dir_path: dir_path.into(),
})
}
fn config_dir_path(&self, domain: &domain::Name) -> PathBuf {
self.dir_path.join(domain.as_str())
}
fn config_file_path(&self, domain: &domain::Name) -> PathBuf {
self.config_dir_path(domain).join("config.json")
}
}
impl Store for FSStore {
fn get(&self, domain: &domain::Name) -> Result<Config, GetError> {
let path = self.config_file_path(domain);
let config_file = fs::File::open(path.as_path()).map_err(|e| match e.kind() {
io::ErrorKind::NotFound => GetError::NotFound,
_ => e
.into_unexpected_while(format!("opening {}", path.display()))
.into(),
})?;
let config = serde_json::from_reader(config_file)
.map_unexpected_while(|| format!("json parsing {}", path.display()))?;
Ok(config)
}
fn set(&self, domain: &domain::Name, config: &Config) -> Result<(), SetError> {
let dir_path = self.config_dir_path(domain);
fs::create_dir_all(dir_path.as_path())
.map_unexpected_while(|| format!("creating dir {}", dir_path.display()))?;
let file_path = self.config_file_path(domain);
let config_file = fs::File::create(file_path.as_path())
.map_unexpected_while(|| format!("creating file {}", file_path.display()))?;
serde_json::to_writer(config_file, config)
.map_unexpected_while(|| format!("writing config to {}", file_path.display()))?;
Ok(())
}
fn all_domains(&self) -> Result<Vec<domain::Name>, unexpected::Error> {
fs::read_dir(&self.dir_path)
.or_unexpected()?
.map(
|dir_entry_res: io::Result<fs::DirEntry>| -> Result<domain::Name, unexpected::Error> {
let domain = dir_entry_res.or_unexpected()?.file_name();
let domain = domain.to_str().ok_or(unexpected::Error::from(
"couldn't convert os string to &str",
))?;
domain::Name::from_str(domain)
.map_unexpected_while(|| format!("parsing {domain} as domain name"))
},
)
.try_collect()
}
}
#[cfg(test)]
mod tests {
use super::{Store, *};
use crate::domain;
use crate::origin::Descr;
use std::str::FromStr;
use tempdir::TempDir;
#[test]
fn basic() {
let tmp_dir = TempDir::new("domain_config_store").unwrap();
let store = FSStore::new(tmp_dir.path()).expect("store created");
let domain = domain::Name::from_str("foo.com").expect("domain parsed");
let config = Config {
origin_descr: Descr::Git {
url: "bar".to_string(),
branch_name: "baz".to_string(),
},
};
assert!(matches!(
store.get(&domain),
Err::<Config, GetError>(GetError::NotFound)
));
store.set(&domain, &config).expect("config set");
assert_eq!(config, store.get(&domain).expect("config retrieved"));
let new_config = Config {
origin_descr: Descr::Git {
url: "BAR".to_string(),
branch_name: "BAZ".to_string(),
},
};
store.set(&domain, &new_config).expect("config set");
assert_eq!(new_config, store.get(&domain).expect("config retrieved"));
}
} }

View File

@ -1,4 +1,4 @@
use crate::domain::{self, acme, checker, config}; use crate::domain::{self, acme, checker, store};
use crate::error::unexpected::{self, Mappable}; use crate::error::unexpected::{self, Mappable};
use crate::origin; use crate::origin;
use crate::util; use crate::util;
@ -15,11 +15,11 @@ pub enum GetConfigError {
Unexpected(#[from] unexpected::Error), Unexpected(#[from] unexpected::Error),
} }
impl From<config::GetError> for GetConfigError { impl From<store::GetError> for GetConfigError {
fn from(e: config::GetError) -> GetConfigError { fn from(e: store::GetError) -> GetConfigError {
match e { match e {
config::GetError::NotFound => GetConfigError::NotFound, store::GetError::NotFound => GetConfigError::NotFound,
config::GetError::Unexpected(e) => GetConfigError::Unexpected(e), store::GetError::Unexpected(e) => GetConfigError::Unexpected(e),
} }
} }
} }
@ -36,11 +36,11 @@ pub enum GetFileError {
Unexpected(#[from] unexpected::Error), Unexpected(#[from] unexpected::Error),
} }
impl From<config::GetError> for GetFileError { impl From<store::GetError> for GetFileError {
fn from(e: config::GetError) -> Self { fn from(e: store::GetError) -> Self {
match e { match e {
config::GetError::NotFound => Self::DomainNotFound, store::GetError::NotFound => Self::DomainNotFound,
config::GetError::Unexpected(e) => Self::Unexpected(e), store::GetError::Unexpected(e) => Self::Unexpected(e),
} }
} }
} }
@ -69,11 +69,11 @@ pub enum SyncError {
Unexpected(#[from] unexpected::Error), Unexpected(#[from] unexpected::Error),
} }
impl From<config::GetError> for SyncError { impl From<store::GetError> for SyncError {
fn from(e: config::GetError) -> SyncError { fn from(e: store::GetError) -> SyncError {
match e { match e {
config::GetError::NotFound => SyncError::NotFound, store::GetError::NotFound => SyncError::NotFound,
config::GetError::Unexpected(e) => SyncError::Unexpected(e), store::GetError::Unexpected(e) => SyncError::Unexpected(e),
} }
} }
} }
@ -89,8 +89,8 @@ pub enum SyncWithConfigError {
#[error("already in progress")] #[error("already in progress")]
AlreadyInProgress, AlreadyInProgress,
#[error("target A/AAAA not set")] #[error("no service dns records set")]
TargetANotSet, ServiceDNSRecordsNotSet,
#[error("challenge token not set")] #[error("challenge token not set")]
ChallengeTokenNotSet, ChallengeTokenNotSet,
@ -113,7 +113,9 @@ impl From<origin::SyncError> for SyncWithConfigError {
impl From<checker::CheckDomainError> for SyncWithConfigError { impl From<checker::CheckDomainError> for SyncWithConfigError {
fn from(e: checker::CheckDomainError) -> SyncWithConfigError { fn from(e: checker::CheckDomainError) -> SyncWithConfigError {
match e { match e {
checker::CheckDomainError::TargetANotSet => SyncWithConfigError::TargetANotSet, checker::CheckDomainError::ServiceDNSRecordsNotSet => {
SyncWithConfigError::ServiceDNSRecordsNotSet
}
checker::CheckDomainError::ChallengeTokenNotSet => { checker::CheckDomainError::ChallengeTokenNotSet => {
SyncWithConfigError::ChallengeTokenNotSet SyncWithConfigError::ChallengeTokenNotSet
} }
@ -122,10 +124,10 @@ impl From<checker::CheckDomainError> for SyncWithConfigError {
} }
} }
impl From<config::SetError> for SyncWithConfigError { impl From<store::SetError> for SyncWithConfigError {
fn from(e: config::SetError) -> SyncWithConfigError { fn from(e: store::SetError) -> SyncWithConfigError {
match e { match e {
config::SetError::Unexpected(e) => SyncWithConfigError::Unexpected(e), store::SetError::Unexpected(e) => SyncWithConfigError::Unexpected(e),
} }
} }
} }
@ -134,7 +136,7 @@ pub type GetAcmeHttp01ChallengeKeyError = acme::manager::GetHttp01ChallengeKeyEr
//#[mockall::automock] //#[mockall::automock]
pub trait Manager: Sync + Send + rustls::server::ResolvesServerCert { pub trait Manager: Sync + Send + rustls::server::ResolvesServerCert {
fn get_config(&self, domain: &domain::Name) -> Result<config::Config, GetConfigError>; fn get_config(&self, domain: &domain::Name) -> Result<domain::Domain, GetConfigError>;
fn get_file<'store>( fn get_file<'store>(
&'store self, &'store self,
@ -150,7 +152,7 @@ pub trait Manager: Sync + Send + rustls::server::ResolvesServerCert {
fn sync_with_config<'mgr>( fn sync_with_config<'mgr>(
&'mgr self, &'mgr self,
domain: domain::Name, domain: domain::Name,
config: config::Config, config: domain::Domain,
) -> util::BoxFuture<'mgr, Result<(), SyncWithConfigError>>; ) -> util::BoxFuture<'mgr, Result<(), SyncWithConfigError>>;
fn get_acme_http01_challenge_key( fn get_acme_http01_challenge_key(
@ -163,7 +165,7 @@ pub trait Manager: Sync + Send + rustls::server::ResolvesServerCert {
pub struct ManagerImpl { pub struct ManagerImpl {
origin_store: Box<dyn origin::Store + Send + Sync>, origin_store: Box<dyn origin::Store + Send + Sync>,
domain_config_store: Box<dyn config::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>>,
} }
@ -171,18 +173,18 @@ pub struct ManagerImpl {
impl ManagerImpl { impl ManagerImpl {
pub fn new< pub fn new<
OriginStore: origin::Store + Send + Sync + 'static, OriginStore: origin::Store + Send + Sync + 'static,
DomainConfigStore: config::Store + Send + Sync + 'static, DomainStore: store::Store + Send + Sync + 'static,
AcmeManager: acme::manager::Manager + Send + Sync + 'static, AcmeManager: acme::manager::Manager + Send + Sync + 'static,
>( >(
task_stack: &mut util::TaskStack<unexpected::Error>, task_stack: &mut util::TaskStack<unexpected::Error>,
origin_store: OriginStore, origin_store: OriginStore,
domain_config_store: DomainConfigStore, domain_store: DomainStore,
domain_checker: checker::DNSChecker, domain_checker: checker::DNSChecker,
acme_manager: Option<AcmeManager>, acme_manager: Option<AcmeManager>,
) -> 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),
domain_config_store: Box::from(domain_config_store), domain_store: Box::from(domain_store),
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>),
@ -224,8 +226,8 @@ impl ManagerImpl {
} }
impl Manager for ManagerImpl { impl Manager for ManagerImpl {
fn get_config(&self, domain: &domain::Name) -> Result<config::Config, GetConfigError> { fn get_config(&self, domain: &domain::Name) -> Result<domain::Domain, GetConfigError> {
Ok(self.domain_config_store.get(domain)?) Ok(self.domain_store.get(domain)?)
} }
fn get_file<'store>( fn get_file<'store>(
@ -233,7 +235,7 @@ impl Manager for ManagerImpl {
domain: &domain::Name, domain: &domain::Name,
path: &str, path: &str,
) -> Result<util::BoxByteStream, GetFileError> { ) -> Result<util::BoxByteStream, GetFileError> {
let config = self.domain_config_store.get(domain)?; let config = self.domain_store.get(domain)?;
let f = self.origin_store.get_file(&config.origin_descr, path)?; let f = self.origin_store.get_file(&config.origin_descr, path)?;
Ok(f) Ok(f)
} }
@ -254,7 +256,7 @@ impl Manager for ManagerImpl {
fn sync_with_config<'mgr>( fn sync_with_config<'mgr>(
&'mgr self, &'mgr self,
domain: domain::Name, domain: domain::Name,
config: config::Config, config: domain::Domain,
) -> util::BoxFuture<'mgr, Result<(), SyncWithConfigError>> { ) -> util::BoxFuture<'mgr, Result<(), SyncWithConfigError>> {
Box::pin(async move { Box::pin(async move {
let config_hash = config let config_hash = config
@ -267,7 +269,7 @@ impl Manager for ManagerImpl {
self.origin_store.sync(&config.origin_descr)?; self.origin_store.sync(&config.origin_descr)?;
self.domain_config_store.set(&domain, &config)?; self.domain_store.set(&domain, &config)?;
self.sync_cert(domain).await?; self.sync_cert(domain).await?;
@ -287,7 +289,7 @@ impl Manager for ManagerImpl {
} }
fn all_domains(&self) -> Result<Vec<domain::Name>, unexpected::Error> { fn all_domains(&self) -> Result<Vec<domain::Name>, unexpected::Error> {
self.domain_config_store.all_domains() self.domain_store.all_domains()
} }
} }

79
src/domain/name.rs Normal file
View File

@ -0,0 +1,79 @@
use std::fmt;
use std::str::FromStr;
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
use trust_dns_client::rr as trust_dns_rr;
#[derive(Debug, Clone)]
/// Validated representation of a domain name
pub struct Name {
inner: trust_dns_rr::Name,
utf8_str: String,
}
impl Name {
pub fn as_str(&self) -> &str {
self.utf8_str.as_str()
}
pub fn as_rr(&self) -> &trust_dns_rr::Name {
&self.inner
}
}
impl fmt::Display for Name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.utf8_str)
}
}
impl FromStr for Name {
type Err = <trust_dns_rr::Name as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let mut n = trust_dns_rr::Name::from_str(s)?;
let utf8_str = n.clone().to_utf8();
n.set_fqdn(true);
Ok(Name { inner: n, utf8_str })
}
}
impl Serialize for Name {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
struct NameVisitor;
impl<'de> de::Visitor<'de> for NameVisitor {
type Value = Name;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
write!(formatter, "a valid domain name")
}
fn visit_str<E>(self, s: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
match Name::from_str(s) {
Ok(n) => Ok(n),
Err(e) => Err(E::custom(format!("invalid domain name: {}", e))),
}
}
}
impl<'de> Deserialize<'de> for Name {
fn deserialize<D>(deserializer: D) -> Result<Name, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_str(NameVisitor)
}
}

142
src/domain/store.rs Normal file
View File

@ -0,0 +1,142 @@
use std::path;
use std::str::FromStr;
use std::{fs, io};
use crate::domain;
use crate::error::unexpected::{self, Intoable, Mappable};
#[derive(thiserror::Error, Debug)]
pub enum GetError {
#[error("not found")]
NotFound,
#[error(transparent)]
Unexpected(#[from] unexpected::Error),
}
#[derive(thiserror::Error, Debug)]
pub enum SetError {
#[error(transparent)]
Unexpected(#[from] unexpected::Error),
}
#[mockall::automock]
pub trait Store {
fn get(&self, domain: &domain::Name) -> Result<domain::Domain, GetError>;
fn set(&self, domain: &domain::Name, config: &domain::Domain) -> Result<(), SetError>;
fn all_domains(&self) -> Result<Vec<domain::Name>, unexpected::Error>;
}
pub struct FSStore {
dir_path: path::PathBuf,
}
impl FSStore {
pub fn new(dir_path: &path::Path) -> io::Result<Self> {
fs::create_dir_all(dir_path)?;
Ok(Self {
dir_path: dir_path.into(),
})
}
fn config_dir_path(&self, domain: &domain::Name) -> path::PathBuf {
self.dir_path.join(domain.as_str())
}
fn config_file_path(&self, domain: &domain::Name) -> path::PathBuf {
self.config_dir_path(domain).join("config.json")
}
}
impl Store for FSStore {
fn get(&self, domain: &domain::Name) -> Result<domain::Domain, GetError> {
let path = self.config_file_path(domain);
let config_file = fs::File::open(path.as_path()).map_err(|e| match e.kind() {
io::ErrorKind::NotFound => GetError::NotFound,
_ => e
.into_unexpected_while(format!("opening {}", path.display()))
.into(),
})?;
let config = serde_json::from_reader(config_file)
.map_unexpected_while(|| format!("json parsing {}", path.display()))?;
Ok(config)
}
fn set(&self, domain: &domain::Name, config: &domain::Domain) -> Result<(), SetError> {
let dir_path = self.config_dir_path(domain);
fs::create_dir_all(dir_path.as_path())
.map_unexpected_while(|| format!("creating dir {}", dir_path.display()))?;
let file_path = self.config_file_path(domain);
let config_file = fs::File::create(file_path.as_path())
.map_unexpected_while(|| format!("creating file {}", file_path.display()))?;
serde_json::to_writer(config_file, config)
.map_unexpected_while(|| format!("writing config to {}", file_path.display()))?;
Ok(())
}
fn all_domains(&self) -> Result<Vec<domain::Name>, unexpected::Error> {
fs::read_dir(&self.dir_path)
.or_unexpected()?
.map(
|dir_entry_res: io::Result<fs::DirEntry>| -> Result<domain::Name, unexpected::Error> {
let domain = dir_entry_res.or_unexpected()?.file_name();
let domain = domain.to_str().ok_or(unexpected::Error::from(
"couldn't convert os string to &str",
))?;
domain::Name::from_str(domain)
.map_unexpected_while(|| format!("parsing {domain} as domain name"))
},
)
.try_collect()
}
}
#[cfg(test)]
mod tests {
use super::{Store, *};
use crate::domain;
use crate::origin::Descr;
use std::str::FromStr;
use tempdir::TempDir;
#[test]
fn basic() {
let tmp_dir = TempDir::new("domain_config_store").unwrap();
let store = FSStore::new(tmp_dir.path()).expect("store created");
let domain = domain::Name::from_str("foo.com").expect("domain parsed");
let config = domain::Domain {
origin_descr: Descr::Git {
url: "bar".to_string(),
branch_name: "baz".to_string(),
},
};
assert!(matches!(
store.get(&domain),
Err::<domain::Domain, GetError>(GetError::NotFound)
));
store.set(&domain, &config).expect("config set");
assert_eq!(config, store.get(&domain).expect("config retrieved"));
let new_config = domain::Domain {
origin_descr: Descr::Git {
url: "BAR".to_string(),
branch_name: "BAZ".to_string(),
},
};
store.set(&domain, &new_config).expect("config set");
assert_eq!(new_config, store.get(&domain).expect("config retrieved"));
}
}

View File

@ -2,6 +2,7 @@
#![feature(iterator_try_collect)] #![feature(iterator_try_collect)]
#![feature(iter_collect_into)] #![feature(iter_collect_into)]
pub mod config;
pub mod domain; pub mod domain;
pub mod error; pub mod error;
pub mod origin; pub mod origin;

View File

@ -4,9 +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::net::SocketAddr;
use std::path; use std::path;
use std::str::FromStr;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(version)] #[command(version)]
@ -23,88 +21,66 @@ struct Cli {
#[arg(long, default_value_t = false, env = "DOMANI_LOG_TIMESTAMP")] #[arg(long, default_value_t = false, env = "DOMANI_LOG_TIMESTAMP")]
log_timestamp: bool, log_timestamp: bool,
#[arg(long, required = true, env = "DOMANI_HTTP_DOMAIN")]
http_domain: domani::domain::Name,
#[arg(long, default_value_t = SocketAddr::from_str("[::]:3030").unwrap(), env = "DOMANI_HTTP_LISTEN_ADDR")]
http_listen_addr: SocketAddr,
#[arg( #[arg(
long, long,
help = "E.g. '[::]:443', if given then SSL certs will automatically be retrieved for all domains using LetsEncrypt", help = "Path to config file",
env = "DOMANI_HTTPS_LISTEN_ADDR", required = true,
requires = "domain_acme_contact_email", env = "DOMANI_CONFIG_PATH"
requires = "domain_acme_store_dir_path"
)] )]
https_listen_addr: Option<SocketAddr>, config_path: path::PathBuf,
#[arg(long, required = true, env = "DOMANI_PASSPHRASE")]
passphrase: String,
#[arg(long, required = true, env = "DOMANI_ORIGIN_STORE_GIT_DIR_PATH")]
origin_store_git_dir_path: path::PathBuf,
#[arg(long, required = true, env = "DOMANI_DOMAIN_CHECKER_TARGET_A")]
domain_checker_target_a: std::net::Ipv4Addr,
#[arg(long, default_value_t = String::from("1.1.1.1:53"), env = "DOMANI_DOMAIN_CHECKER_RESOLVER_ADDR")]
domain_checker_resolver_addr: String,
#[arg(long, required = true, env = "DOMANI_DOMAIN_CONFIG_STORE_DIR_PATH")]
domain_config_store_dir_path: path::PathBuf,
#[arg(long, env = "DOMANI_DOMAIN_ACME_STORE_DIR_PATH")]
domain_acme_store_dir_path: Option<path::PathBuf>,
#[arg(long, env = "DOMANI_DOMAIN_ACME_CONTACT_EMAIL")]
domain_acme_contact_email: Option<String>,
} }
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let config = Cli::parse(); let cli = Cli::parse();
let config: domani::config::Config = {
let path = &cli.config_path;
let f = std::fs::File::open(path)
.unwrap_or_else(|e| panic!("failed to open config file at {}: {e}", path.display()));
serde_yaml::from_reader(f)
.unwrap_or_else(|e| panic!("failed to parse config file at {}: {e}", path.display()))
};
env_logger::Builder::new() env_logger::Builder::new()
.filter_level(config.log_level) .filter_level(cli.log_level)
.format_timestamp( .format_timestamp(
config cli.log_timestamp
.log_timestamp
.then_some(env_logger::TimestampPrecision::Micros), .then_some(env_logger::TimestampPrecision::Micros),
) )
.init(); .init();
let origin_store = domani::origin::git::FSStore::new(config.origin_store_git_dir_path) let origin_store = domani::origin::git::FSStore::new(&config.origin)
.expect("git origin store initialization failed"); .expect("git origin store initialization failed");
let domain_checker = domani::domain::checker::DNSChecker::new( let domain_checker = {
config.domain_checker_target_a, let dns_records = config.service.dns_records.clone();
&config.domain_checker_resolver_addr, domani::domain::checker::DNSChecker::new(
) &config.domain.dns,
.await dns_records.into_iter().map(|r| r.into()).collect(),
.expect("domain checker initialization failed"); )
.await
.expect("domain checker initialization failed")
};
let domain_config_store = let domain_config_store =
domani::domain::config::FSStore::new(&config.domain_config_store_dir_path) domani::domain::store::FSStore::new(&config.domain.store_dir_path.join("domains"))
.expect("domain config store initialization failed"); .expect("domain config store initialization failed");
let domain_acme_manager = if config.https_listen_addr.is_some() { let domain_acme_manager = if config.service.http.https_addr.is_some() {
let domain_acme_store_dir_path = config.domain_acme_store_dir_path.unwrap(); let acme_config = config
.domain
.acme
.expect("acme configuration must be set if https is enabled");
let domain_acme_store = let domain_acme_store =
domani::domain::acme::store::FSStore::new(&domain_acme_store_dir_path) domani::domain::acme::store::FSStore::new(&config.domain.store_dir_path.join("acme"))
.expect("domain acme store initialization failed"); .expect("domain acme store initialization failed");
// if https_listen_addr is set then domain_acme_contact_email is required, see the Cli/clap
// settings.
let domain_acme_contact_email = config.domain_acme_contact_email.unwrap();
Some( Some(
domani::domain::acme::manager::ManagerImpl::new( domani::domain::acme::manager::ManagerImpl::new(domain_acme_store, &acme_config)
domain_acme_store, .await
&domain_acme_contact_email, .expect("domain acme manager initialization failed"),
)
.await
.expect("domain acme manager initialization failed"),
) )
} else { } else {
None None
@ -123,16 +99,8 @@ async fn main() {
let _ = domani::service::http::new( let _ = domani::service::http::new(
&mut task_stack, &mut task_stack,
domain_manager.clone(), domain_manager.clone(),
config.domain_checker_target_a, domain_manager.clone(),
config.passphrase, config.service,
config.http_listen_addr,
config.http_domain,
config
.https_listen_addr
.map(|listen_addr| domani::service::http::HTTPSParams {
listen_addr,
cert_resolver: domain_manager.clone(),
}),
); );
let mut signals = let mut signals =

View File

@ -1,13 +1,15 @@
use crate::error::unexpected; mod config;
use crate::util; mod descr;
use std::sync;
pub mod git; pub mod git;
pub mod mux; pub mod mux;
mod descr; pub use config::*;
pub use descr::Descr; pub use descr::Descr;
use crate::error::unexpected;
use crate::util;
use std::sync;
#[derive(thiserror::Error, Clone, Debug, PartialEq)] #[derive(thiserror::Error, Clone, Debug, PartialEq)]
pub enum SyncError { pub enum SyncError {
#[error("invalid url")] #[error("invalid url")]

7
src/origin/config.rs Normal file
View File

@ -0,0 +1,7 @@
use serde::Deserialize;
use std::path;
#[derive(Deserialize)]
pub struct Config {
pub store_dir_path: path::PathBuf,
}

View File

@ -34,7 +34,8 @@ pub struct FSStore {
} }
impl FSStore { impl FSStore {
pub fn new(dir_path: PathBuf) -> io::Result<Self> { pub fn new(config: &origin::Config) -> io::Result<Self> {
let dir_path = config.store_dir_path.join("git");
fs::create_dir_all(&dir_path)?; fs::create_dir_all(&dir_path)?;
Ok(Self { Ok(Self {
dir_path, dir_path,
@ -336,13 +337,16 @@ impl super::Store for FSStore {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::origin::{self, Store}; use crate::origin::{self, Config, Store};
use futures::StreamExt; use futures::StreamExt;
use tempdir::TempDir; use tempdir::TempDir;
#[tokio::test] #[tokio::test]
async fn basic() { async fn basic() {
let tmp_dir = TempDir::new("origin_store_git").unwrap(); let tmp_dir = TempDir::new("origin_store_git").unwrap();
let config = Config {
store_dir_path: tmp_dir.path().to_path_buf(),
};
let curr_dir = format!("file://{}", std::env::current_dir().unwrap().display()); let curr_dir = format!("file://{}", std::env::current_dir().unwrap().display());
@ -356,7 +360,7 @@ mod tests {
branch_name: String::from("some_other_branch"), branch_name: String::from("some_other_branch"),
}; };
let store = super::FSStore::new(tmp_dir.path().to_path_buf()).expect("store created"); let store = super::FSStore::new(&config).expect("store created");
store.sync(&descr).expect("sync should succeed"); store.sync(&descr).expect("sync should succeed");
store.sync(&descr).expect("second sync should succeed"); store.sync(&descr).expect("second sync should succeed");

View File

@ -1,2 +1,34 @@
pub mod http; pub mod http;
mod util; mod util;
use crate::domain;
use serde::Deserialize;
use std::{net, str::FromStr};
fn default_primary_domain() -> domain::Name {
domain::Name::from_str("localhost").unwrap()
}
#[derive(Deserialize, Clone)]
#[serde(tag = "type")]
pub enum ConfigDNSRecord {
A { addr: net::Ipv4Addr },
}
impl From<ConfigDNSRecord> for domain::checker::DNSRecord {
fn from(r: ConfigDNSRecord) -> Self {
match r {
ConfigDNSRecord::A { addr } => Self::A(addr),
}
}
}
#[derive(Deserialize)]
pub struct Config {
#[serde(default = "default_primary_domain")]
pub primary_domain: domain::Name,
pub passphrase: String,
pub dns_records: Vec<ConfigDNSRecord>,
#[serde(default)]
pub http: self::http::Config,
}

View File

@ -1,6 +1,9 @@
mod config;
mod tasks; mod tasks;
mod tpl; mod tpl;
pub use config::*;
use hyper::{Body, Method, Request, Response}; use hyper::{Body, Method, Request, Response};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -12,57 +15,31 @@ use crate::{domain, service, util};
pub struct Service { pub struct Service {
domain_manager: sync::Arc<dyn domain::manager::Manager>, domain_manager: sync::Arc<dyn domain::manager::Manager>,
target_a: net::Ipv4Addr, cert_resolver: sync::Arc<dyn rustls::server::ResolvesServerCert>,
passphrase: String,
http_domain: domain::Name,
handlebars: handlebars::Handlebars<'static>, handlebars: handlebars::Handlebars<'static>,
} config: service::Config,
pub struct HTTPSParams {
pub listen_addr: net::SocketAddr,
pub cert_resolver: sync::Arc<dyn rustls::server::ResolvesServerCert>,
} }
pub fn new( pub fn new(
task_stack: &mut util::TaskStack<unexpected::Error>, task_stack: &mut util::TaskStack<unexpected::Error>,
domain_manager: sync::Arc<dyn domain::manager::Manager>, domain_manager: sync::Arc<dyn domain::manager::Manager>,
target_a: net::Ipv4Addr, cert_resolver: sync::Arc<dyn rustls::server::ResolvesServerCert>,
passphrase: String, config: service::Config,
http_listen_addr: net::SocketAddr,
http_domain: domain::Name,
https_params: Option<HTTPSParams>,
) -> sync::Arc<Service> { ) -> sync::Arc<Service> {
let https_enabled = config.http.https_addr.is_some();
let service = sync::Arc::new(Service { let service = sync::Arc::new(Service {
domain_manager: domain_manager.clone(), domain_manager: domain_manager.clone(),
target_a, cert_resolver,
passphrase,
http_domain: http_domain.clone(),
handlebars: tpl::get(), handlebars: tpl::get(),
config,
}); });
task_stack.push_spawn(|canceller| { task_stack.push_spawn(|canceller| tasks::listen_http(service.clone(), canceller));
tasks::listen_http(
service.clone(),
canceller,
http_listen_addr,
http_domain.clone(),
)
});
if let Some(https_params) = https_params { if https_enabled {
task_stack.push_spawn(|canceller| { task_stack.push_spawn(|canceller| tasks::listen_https(service.clone(), canceller));
tasks::listen_https( task_stack.push_spawn(|canceller| tasks::cert_refresher(service.clone(), canceller));
service.clone(),
canceller,
https_params.cert_resolver.clone(),
https_params.listen_addr,
http_domain.clone(),
)
});
task_stack.push_spawn(|canceller| {
tasks::cert_refresher(domain_manager.clone(), canceller, http_domain.clone())
});
} }
service service
@ -216,7 +193,7 @@ impl<'svc> Service {
#[derive(Serialize)] #[derive(Serialize)]
struct Response { struct Response {
domain: domain::Name, domain: domain::Name,
config: Option<domain::config::Config>, config: Option<domain::Domain>,
} }
let config = match self.domain_manager.get_config(&args.domain) { let config = match self.domain_manager.get_config(&args.domain) {
@ -255,7 +232,7 @@ impl<'svc> Service {
challenge_token: String, challenge_token: String,
} }
let config: domain::config::Config = match domain_config.try_into() { let config: domain::Domain = match domain_config.try_into() {
Ok(Some(config)) => config, Ok(Some(config)) => config,
Ok(None) => return self.render_error_page(400, "domain config is required"), Ok(None) => return self.render_error_page(400, "domain config is required"),
Err(e) => { Err(e) => {
@ -272,12 +249,21 @@ impl<'svc> Service {
} }
}; };
let target_a = match self
.config
.dns_records
.get(0)
.expect("at least one target record expected")
{
service::ConfigDNSRecord::A { addr } => addr.clone(),
};
self.render_page( self.render_page(
"/domain_init.html", "/domain_init.html",
Response { Response {
domain: args.domain, domain: args.domain,
flat_config: config.into(), flat_config: config.into(),
target_a: self.target_a, target_a: target_a,
challenge_token: config_hash, challenge_token: config_hash,
}, },
) )
@ -288,11 +274,11 @@ impl<'svc> Service {
args: DomainSyncArgs, args: DomainSyncArgs,
domain_config: service::util::FlatConfig, domain_config: service::util::FlatConfig,
) -> Response<Body> { ) -> Response<Body> {
if args.passphrase != self.passphrase.as_str() { if args.passphrase != self.config.passphrase.as_str() {
return self.render_error_page(401, "Incorrect passphrase"); return self.render_error_page(401, "Incorrect passphrase");
} }
let config: domain::config::Config = match domain_config.try_into() { let config: domain::Domain = match domain_config.try_into() {
Ok(Some(config)) => config, Ok(Some(config)) => config,
Ok(None) => return self.render_error_page(400, "domain config is required"), Ok(None) => return self.render_error_page(400, "domain config is required"),
Err(e) => { Err(e) => {
@ -316,7 +302,7 @@ impl<'svc> Service {
Err(domain::manager::SyncWithConfigError::InvalidURL) => Some("Fetching the git repository failed, please double check that you input the correct URL.".to_string()), Err(domain::manager::SyncWithConfigError::InvalidURL) => Some("Fetching the git repository failed, please double check that you input the correct URL.".to_string()),
Err(domain::manager::SyncWithConfigError::InvalidBranchName) => Some("The git repository does not have a branch of the given name, please double check that you input the correct name.".to_string()), Err(domain::manager::SyncWithConfigError::InvalidBranchName) => Some("The git repository does not have a branch of the given name, please double check that you input the correct name.".to_string()),
Err(domain::manager::SyncWithConfigError::AlreadyInProgress) => Some("The configuration of your domain is still in progress, please refresh in a few minutes.".to_string()), Err(domain::manager::SyncWithConfigError::AlreadyInProgress) => Some("The configuration of your domain is still in progress, please refresh in a few minutes.".to_string()),
Err(domain::manager::SyncWithConfigError::TargetANotSet) => Some("The A record is not set correctly on the domain. Please double check that you put the correct value on the record. If the value is correct, then most likely the updated records have not yet propagated. In this case you can refresh in a few minutes to try again.".to_string()), Err(domain::manager::SyncWithConfigError::ServiceDNSRecordsNotSet) => Some("None of the expected service DNS records were set on the domain. Please double check that you put the correct value on the record. If the value is correct, then most likely the updated records have not yet propagated. In this case you can refresh in a few minutes to try again.".to_string()),
Err(domain::manager::SyncWithConfigError::ChallengeTokenNotSet) => Some("The TXT record is not set correctly on the domain. Please double check that you put the correct value on the record. If the value is correct, then most likely the updated records have not yet propagated. In this case you can refresh in a few minutes to try again.".to_string()), Err(domain::manager::SyncWithConfigError::ChallengeTokenNotSet) => Some("The TXT record is not set correctly on the domain. Please double check that you put the correct value on the record. If the value is correct, then most likely the updated records have not yet propagated. In this case you can refresh in a few minutes to try again.".to_string()),
Err(domain::manager::SyncWithConfigError::Unexpected(e)) => Some(format!("An unexpected error occurred: {e}")), Err(domain::manager::SyncWithConfigError::Unexpected(e)) => Some(format!("An unexpected error occurred: {e}")),
}; };
@ -358,8 +344,8 @@ impl<'svc> Service {
.map(strip_port), .map(strip_port),
req.uri().host().map(strip_port), req.uri().host().map(strip_port),
) { ) {
(Some(h), _) if h != self.http_domain.as_str() => Some(h), (Some(h), _) if h != self.config.primary_domain.as_str() => Some(h),
(_, Some(h)) if h != self.http_domain.as_str() => Some(h), (_, Some(h)) if h != self.config.primary_domain.as_str() => Some(h),
_ => None, _ => None,
} }
.and_then(|h| domain::Name::from_str(h).ok()); .and_then(|h| domain::Name::from_str(h).ok());

View File

@ -0,0 +1,22 @@
use serde::Deserialize;
use std::{net, str::FromStr};
fn default_http_addr() -> net::SocketAddr {
net::SocketAddr::from_str("[::]:3030").unwrap()
}
#[derive(Deserialize)]
pub struct Config {
#[serde(default = "default_http_addr")]
pub http_addr: net::SocketAddr,
pub https_addr: Option<net::SocketAddr>,
}
impl Default for Config {
fn default() -> Self {
Self {
http_addr: default_http_addr(),
https_addr: None,
}
}
}

View File

@ -1,7 +1,7 @@
use crate::error::unexpected::{self, Mappable}; use crate::error::unexpected::{self, Mappable};
use crate::{domain, service}; use crate::service;
use std::{convert, future, net, sync}; use std::{convert, future, sync};
use futures::StreamExt; use futures::StreamExt;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
@ -9,9 +9,10 @@ use tokio_util::sync::CancellationToken;
pub async fn listen_http( pub async fn listen_http(
service: sync::Arc<service::http::Service>, service: sync::Arc<service::http::Service>,
canceller: CancellationToken, canceller: CancellationToken,
addr: net::SocketAddr,
domain: domain::Name,
) -> Result<(), unexpected::Error> { ) -> Result<(), unexpected::Error> {
let addr = service.config.http.http_addr.clone();
let primary_domain = service.config.primary_domain.clone();
let make_service = hyper::service::make_service_fn(move |_| { let make_service = hyper::service::make_service_fn(move |_| {
let service = service.clone(); let service = service.clone();
@ -25,7 +26,11 @@ pub async fn listen_http(
async move { Ok::<_, convert::Infallible>(hyper_service) } async move { Ok::<_, convert::Infallible>(hyper_service) }
}); });
log::info!("Listening on http://{}:{}", domain.as_str(), addr.port()); log::info!(
"Listening on http://{}:{}",
primary_domain.as_str(),
addr.port(),
);
let server = hyper::Server::bind(&addr).serve(make_service); let server = hyper::Server::bind(&addr).serve(make_service);
let graceful = server.with_graceful_shutdown(async { let graceful = server.with_graceful_shutdown(async {
@ -38,10 +43,11 @@ pub async fn listen_http(
pub async fn listen_https( pub async fn listen_https(
service: sync::Arc<service::http::Service>, service: sync::Arc<service::http::Service>,
canceller: CancellationToken, canceller: CancellationToken,
cert_resolver: sync::Arc<dyn rustls::server::ResolvesServerCert>,
addr: net::SocketAddr,
domain: domain::Name,
) -> Result<(), unexpected::Error> { ) -> Result<(), unexpected::Error> {
let cert_resolver = service.cert_resolver.clone();
let addr = service.config.http.https_addr.unwrap().clone();
let primary_domain = service.config.primary_domain.clone();
let make_service = hyper::service::make_service_fn(move |_| { let make_service = hyper::service::make_service_fn(move |_| {
let service = service.clone(); let service = service.clone();
@ -77,7 +83,11 @@ pub async fn listen_https(
let incoming = hyper::server::accept::from_stream(incoming); let incoming = hyper::server::accept::from_stream(incoming);
log::info!("Listening on https://{}:{}", domain.as_str(), addr.port()); log::info!(
"Listening on https://{}:{}",
primary_domain.as_str(),
addr.port()
);
let server = hyper::Server::builder(incoming).serve(make_service); let server = hyper::Server::builder(incoming).serve(make_service);
@ -89,12 +99,13 @@ pub async fn listen_https(
} }
pub async fn cert_refresher( pub async fn cert_refresher(
domain_manager: sync::Arc<dyn domain::manager::Manager>, service: sync::Arc<service::http::Service>,
canceller: CancellationToken, canceller: CancellationToken,
http_domain: domain::Name,
) -> Result<(), unexpected::Error> { ) -> Result<(), unexpected::Error> {
use tokio::time; use tokio::time;
let domain_manager = service.domain_manager.clone();
let primary_domain = service.config.primary_domain.clone();
let mut interval = time::interval(time::Duration::from_secs(60 * 60)); let mut interval = time::interval(time::Duration::from_secs(60 * 60));
loop { loop {
@ -104,12 +115,12 @@ pub async fn cert_refresher(
} }
_ = domain_manager _ = domain_manager
.sync_cert(http_domain.clone()) .sync_cert(primary_domain.clone())
.await .await
.inspect_err(|err| { .inspect_err(|err| {
log::error!( log::error!(
"Error while getting cert for {}: {err}", "Error while getting cert for {}: {err}",
http_domain.as_str() primary_domain.as_str()
) )
}); });

View File

@ -11,7 +11,7 @@ pub struct FlatConfig {
config_origin_descr_git_branch_name: Option<String>, config_origin_descr_git_branch_name: Option<String>,
} }
impl TryFrom<FlatConfig> for Option<domain::config::Config> { impl TryFrom<FlatConfig> for Option<domain::Domain> {
type Error = String; type Error = String;
fn try_from(v: FlatConfig) -> Result<Self, Self::Error> { fn try_from(v: FlatConfig) -> Result<Self, Self::Error> {
@ -21,7 +21,7 @@ impl TryFrom<FlatConfig> for Option<domain::config::Config> {
.as_str() .as_str()
{ {
"" => Ok(None), "" => Ok(None),
"git" => Ok(Some(domain::config::Config { "git" => Ok(Some(domain::Domain {
origin_descr: origin::Descr::Git { origin_descr: origin::Descr::Git {
url: v url: v
.config_origin_descr_git_url .config_origin_descr_git_url
@ -36,8 +36,8 @@ impl TryFrom<FlatConfig> for Option<domain::config::Config> {
} }
} }
impl From<domain::config::Config> for FlatConfig { impl From<domain::Domain> for FlatConfig {
fn from(v: domain::config::Config) -> Self { fn from(v: domain::Domain) -> Self {
match v.origin_descr { match v.origin_descr {
origin::Descr::Git { url, branch_name } => FlatConfig { origin::Descr::Git { url, branch_name } => FlatConfig {
config_origin_descr_kind: Some("git".to_string()), config_origin_descr_kind: Some("git".to_string()),