Compare commits

..

No commits in common. "ceb2ba3cf44e90c49d0d5dfdd2fed03c9825ad6e" and "9d44593e73d2b290f60dc37a6aa441d240a60e37" have entirely different histories.

23 changed files with 465 additions and 600 deletions

View File

@ -1,9 +0,0 @@
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 +1,6 @@
export DOMANI_CONFIG_PATH=./.dev-config.yml
export DOMANI_HTTP_DOMAIN=localhost
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

13
.github/workflows/build_nix.yml vendored Normal file
View File

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

View File

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

View File

@ -1,8 +0,0 @@
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,31 +1,80 @@
pub mod acme;
pub mod checker;
mod config;
pub mod config;
pub mod manager;
mod name;
pub mod store;
pub use config::*;
pub use name::*;
use std::fmt;
use std::str::FromStr;
use crate::error::unexpected::{self, Mappable};
use crate::origin;
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
use trust_dns_client::rr as trust_dns_rr;
use hex::ToHex;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[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,
#[derive(Debug, Clone)]
/// Validated representation of a domain name
pub struct Name {
inner: trust_dns_rr::Name,
utf8_str: String,
}
impl Domain {
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>())
impl Name {
pub fn as_str(&self) -> &str {
self.utf8_str.as_str()
}
}
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 {
pub async fn new<Store: acme::store::Store + Send + Sync + 'static>(
store: Store,
config: &domain::ConfigACME,
contact_email: &str,
) -> Result<Self, unexpected::Error> {
let dir = acme2::DirectoryBuilder::new(LETS_ENCRYPT_URL.to_string())
.build()
@ -41,7 +41,7 @@ impl ManagerImpl {
.or_unexpected_while("creating acme2 directory builder")?;
let mut contact = String::from("mailto:");
contact.push_str(config.contact_email.as_str());
contact.push_str(contact_email);
let mut builder = acme2::AccountBuilder::new(dir);
builder.contact(vec![contact]);

View File

@ -1,5 +1,4 @@
use std::net;
use std::ops::DerefMut;
use std::str::FromStr;
use crate::domain;
@ -9,10 +8,19 @@ use trust_dns_client::client::{AsyncClient, ClientHandle};
use trust_dns_client::rr::{DNSClass, Name, RData, RecordType};
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)]
pub enum CheckDomainError {
#[error("no service dns records set")]
ServiceDNSRecordsNotSet,
#[error("target A not set")]
TargetANotSet,
#[error("challenge token not set")]
ChallengeTokenNotSet,
@ -21,65 +29,31 @@ pub enum CheckDomainError {
Unexpected(#[from] unexpected::Error),
}
pub enum DNSRecord {
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 {
target_a: net::Ipv4Addr,
// TODO we should use some kind of connection pool here, I suppose
client: tokio::sync::Mutex<AsyncClient>,
service_dns_records: Vec<DNSRecord>,
}
impl DNSChecker {
pub async fn new(
config: &domain::ConfigDNS,
service_dns_records: Vec<DNSRecord>,
) -> Result<Self, unexpected::Error> {
let stream = udp::UdpClientStream::<tokio::net::UdpSocket>::new(config.resolver_addr);
target_a: net::Ipv4Addr,
resolver_addr: &str,
) -> Result<Self, NewDNSCheckerError> {
let resolver_addr = 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()?;
tokio::spawn(bg);
// TODO there should be a mechanism to clean this up
Ok(Self {
target_a,
client: tokio::sync::Mutex::new(client),
service_dns_records,
})
}
@ -88,7 +62,31 @@ impl DNSChecker {
domain: &domain::Name,
challenge_token: &str,
) -> Result<(), CheckDomainError> {
let domain = domain.as_rr();
let domain = &domain.inner;
// 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
{
@ -117,16 +115,6 @@ impl DNSChecker {
}
}
// 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)
Ok(())
}
}

View File

@ -1,34 +1,160 @@
use std::{net, path, str::FromStr};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::{fs, io};
use serde::Deserialize;
use crate::error::unexpected::{self, Intoable, Mappable};
use crate::{domain, origin};
fn default_resolver_addr() -> net::SocketAddr {
net::SocketAddr::from_str("1.1.1.1:53").unwrap()
}
use hex::ToHex;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Deserialize)]
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)]
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
/// Values which the owner of a domain can configure when they install a domain.
pub struct Config {
pub store_dir_path: path::PathBuf,
#[serde(default)]
pub dns: ConfigDNS,
pub acme: Option<ConfigACME>,
pub origin_descr: origin::Descr,
}
impl Config {
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, store};
use crate::domain::{self, acme, checker, config};
use crate::error::unexpected::{self, Mappable};
use crate::origin;
use crate::util;
@ -15,11 +15,11 @@ pub enum GetConfigError {
Unexpected(#[from] unexpected::Error),
}
impl From<store::GetError> for GetConfigError {
fn from(e: store::GetError) -> GetConfigError {
impl From<config::GetError> for GetConfigError {
fn from(e: config::GetError) -> GetConfigError {
match e {
store::GetError::NotFound => GetConfigError::NotFound,
store::GetError::Unexpected(e) => GetConfigError::Unexpected(e),
config::GetError::NotFound => GetConfigError::NotFound,
config::GetError::Unexpected(e) => GetConfigError::Unexpected(e),
}
}
}
@ -36,11 +36,11 @@ pub enum GetFileError {
Unexpected(#[from] unexpected::Error),
}
impl From<store::GetError> for GetFileError {
fn from(e: store::GetError) -> Self {
impl From<config::GetError> for GetFileError {
fn from(e: config::GetError) -> Self {
match e {
store::GetError::NotFound => Self::DomainNotFound,
store::GetError::Unexpected(e) => Self::Unexpected(e),
config::GetError::NotFound => Self::DomainNotFound,
config::GetError::Unexpected(e) => Self::Unexpected(e),
}
}
}
@ -69,11 +69,11 @@ pub enum SyncError {
Unexpected(#[from] unexpected::Error),
}
impl From<store::GetError> for SyncError {
fn from(e: store::GetError) -> SyncError {
impl From<config::GetError> for SyncError {
fn from(e: config::GetError) -> SyncError {
match e {
store::GetError::NotFound => SyncError::NotFound,
store::GetError::Unexpected(e) => SyncError::Unexpected(e),
config::GetError::NotFound => SyncError::NotFound,
config::GetError::Unexpected(e) => SyncError::Unexpected(e),
}
}
}
@ -89,8 +89,8 @@ pub enum SyncWithConfigError {
#[error("already in progress")]
AlreadyInProgress,
#[error("no service dns records set")]
ServiceDNSRecordsNotSet,
#[error("target A/AAAA not set")]
TargetANotSet,
#[error("challenge token not set")]
ChallengeTokenNotSet,
@ -113,9 +113,7 @@ impl From<origin::SyncError> for SyncWithConfigError {
impl From<checker::CheckDomainError> for SyncWithConfigError {
fn from(e: checker::CheckDomainError) -> SyncWithConfigError {
match e {
checker::CheckDomainError::ServiceDNSRecordsNotSet => {
SyncWithConfigError::ServiceDNSRecordsNotSet
}
checker::CheckDomainError::TargetANotSet => SyncWithConfigError::TargetANotSet,
checker::CheckDomainError::ChallengeTokenNotSet => {
SyncWithConfigError::ChallengeTokenNotSet
}
@ -124,10 +122,10 @@ impl From<checker::CheckDomainError> for SyncWithConfigError {
}
}
impl From<store::SetError> for SyncWithConfigError {
fn from(e: store::SetError) -> SyncWithConfigError {
impl From<config::SetError> for SyncWithConfigError {
fn from(e: config::SetError) -> SyncWithConfigError {
match e {
store::SetError::Unexpected(e) => SyncWithConfigError::Unexpected(e),
config::SetError::Unexpected(e) => SyncWithConfigError::Unexpected(e),
}
}
}
@ -136,7 +134,7 @@ pub type GetAcmeHttp01ChallengeKeyError = acme::manager::GetHttp01ChallengeKeyEr
//#[mockall::automock]
pub trait Manager: Sync + Send + rustls::server::ResolvesServerCert {
fn get_config(&self, domain: &domain::Name) -> Result<domain::Domain, GetConfigError>;
fn get_config(&self, domain: &domain::Name) -> Result<config::Config, GetConfigError>;
fn get_file<'store>(
&'store self,
@ -152,7 +150,7 @@ pub trait Manager: Sync + Send + rustls::server::ResolvesServerCert {
fn sync_with_config<'mgr>(
&'mgr self,
domain: domain::Name,
config: domain::Domain,
config: config::Config,
) -> util::BoxFuture<'mgr, Result<(), SyncWithConfigError>>;
fn get_acme_http01_challenge_key(
@ -165,7 +163,7 @@ pub trait Manager: Sync + Send + rustls::server::ResolvesServerCert {
pub struct ManagerImpl {
origin_store: Box<dyn origin::Store + Send + Sync>,
domain_store: Box<dyn store::Store + Send + Sync>,
domain_config_store: Box<dyn config::Store + Send + Sync>,
domain_checker: checker::DNSChecker,
acme_manager: Option<Box<dyn acme::manager::Manager + Send + Sync>>,
}
@ -173,18 +171,18 @@ pub struct ManagerImpl {
impl ManagerImpl {
pub fn new<
OriginStore: origin::Store + Send + Sync + 'static,
DomainStore: store::Store + Send + Sync + 'static,
DomainConfigStore: config::Store + Send + Sync + 'static,
AcmeManager: acme::manager::Manager + Send + Sync + 'static,
>(
task_stack: &mut util::TaskStack<unexpected::Error>,
origin_store: OriginStore,
domain_store: DomainStore,
domain_config_store: DomainConfigStore,
domain_checker: checker::DNSChecker,
acme_manager: Option<AcmeManager>,
) -> sync::Arc<Self> {
let manager = sync::Arc::new(ManagerImpl {
origin_store: Box::from(origin_store),
domain_store: Box::from(domain_store),
domain_config_store: Box::from(domain_config_store),
domain_checker,
acme_manager: acme_manager
.map(|m| Box::new(m) as Box<dyn acme::manager::Manager + Send + Sync>),
@ -226,8 +224,8 @@ impl ManagerImpl {
}
impl Manager for ManagerImpl {
fn get_config(&self, domain: &domain::Name) -> Result<domain::Domain, GetConfigError> {
Ok(self.domain_store.get(domain)?)
fn get_config(&self, domain: &domain::Name) -> Result<config::Config, GetConfigError> {
Ok(self.domain_config_store.get(domain)?)
}
fn get_file<'store>(
@ -235,7 +233,7 @@ impl Manager for ManagerImpl {
domain: &domain::Name,
path: &str,
) -> Result<util::BoxByteStream, GetFileError> {
let config = self.domain_store.get(domain)?;
let config = self.domain_config_store.get(domain)?;
let f = self.origin_store.get_file(&config.origin_descr, path)?;
Ok(f)
}
@ -256,7 +254,7 @@ impl Manager for ManagerImpl {
fn sync_with_config<'mgr>(
&'mgr self,
domain: domain::Name,
config: domain::Domain,
config: config::Config,
) -> util::BoxFuture<'mgr, Result<(), SyncWithConfigError>> {
Box::pin(async move {
let config_hash = config
@ -269,7 +267,7 @@ impl Manager for ManagerImpl {
self.origin_store.sync(&config.origin_descr)?;
self.domain_store.set(&domain, &config)?;
self.domain_config_store.set(&domain, &config)?;
self.sync_cert(domain).await?;
@ -289,7 +287,7 @@ impl Manager for ManagerImpl {
}
fn all_domains(&self) -> Result<Vec<domain::Name>, unexpected::Error> {
self.domain_store.all_domains()
self.domain_config_store.all_domains()
}
}

View File

@ -1,79 +0,0 @@
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)
}
}

View File

@ -1,142 +0,0 @@
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,7 +2,6 @@
#![feature(iterator_try_collect)]
#![feature(iter_collect_into)]
pub mod config;
pub mod domain;
pub mod error;
pub mod origin;

View File

@ -4,7 +4,9 @@ use clap::Parser;
use futures::stream::StreamExt;
use signal_hook_tokio::Signals;
use std::net::SocketAddr;
use std::path;
use std::str::FromStr;
#[derive(Parser, Debug)]
#[command(version)]
@ -21,64 +23,86 @@ struct Cli {
#[arg(long, default_value_t = false, env = "DOMANI_LOG_TIMESTAMP")]
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(
long,
help = "Path to config file",
required = true,
env = "DOMANI_CONFIG_PATH"
help = "E.g. '[::]:443', if given then SSL certs will automatically be retrieved for all domains using LetsEncrypt",
env = "DOMANI_HTTPS_LISTEN_ADDR",
requires = "domain_acme_contact_email",
requires = "domain_acme_store_dir_path"
)]
config_path: path::PathBuf,
https_listen_addr: Option<SocketAddr>,
#[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]
async fn main() {
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()))
};
let config = Cli::parse();
env_logger::Builder::new()
.filter_level(cli.log_level)
.filter_level(config.log_level)
.format_timestamp(
cli.log_timestamp
config
.log_timestamp
.then_some(env_logger::TimestampPrecision::Micros),
)
.init();
let origin_store = domani::origin::git::FSStore::new(&config.origin)
let origin_store = domani::origin::git::FSStore::new(config.origin_store_git_dir_path)
.expect("git origin store initialization failed");
let domain_checker = {
let dns_records = config.service.dns_records.clone();
domani::domain::checker::DNSChecker::new(
&config.domain.dns,
dns_records.into_iter().map(|r| r.into()).collect(),
let domain_checker = domani::domain::checker::DNSChecker::new(
config.domain_checker_target_a,
&config.domain_checker_resolver_addr,
)
.await
.expect("domain checker initialization failed")
};
.expect("domain checker initialization failed");
let domain_config_store =
domani::domain::store::FSStore::new(&config.domain.store_dir_path.join("domains"))
domani::domain::config::FSStore::new(&config.domain_config_store_dir_path)
.expect("domain config store initialization failed");
let domain_acme_manager = if config.service.http.https_addr.is_some() {
let acme_config = config
.domain
.acme
.expect("acme configuration must be set if https is enabled");
let domain_acme_manager = if config.https_listen_addr.is_some() {
let domain_acme_store_dir_path = config.domain_acme_store_dir_path.unwrap();
let domain_acme_store =
domani::domain::acme::store::FSStore::new(&config.domain.store_dir_path.join("acme"))
domani::domain::acme::store::FSStore::new(&domain_acme_store_dir_path)
.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(
domani::domain::acme::manager::ManagerImpl::new(domain_acme_store, &acme_config)
domani::domain::acme::manager::ManagerImpl::new(
domain_acme_store,
&domain_acme_contact_email,
)
.await
.expect("domain acme manager initialization failed"),
)
@ -99,8 +123,16 @@ async fn main() {
let _ = domani::service::http::new(
&mut task_stack,
domain_manager.clone(),
domain_manager.clone(),
config.service,
config.domain_checker_target_a,
config.passphrase,
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 =

View File

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

View File

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

View File

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

View File

@ -1,34 +1,2 @@
pub mod http;
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,9 +1,6 @@
mod config;
mod tasks;
mod tpl;
pub use config::*;
use hyper::{Body, Method, Request, Response};
use serde::{Deserialize, Serialize};
@ -15,31 +12,57 @@ use crate::{domain, service, util};
pub struct Service {
domain_manager: sync::Arc<dyn domain::manager::Manager>,
cert_resolver: sync::Arc<dyn rustls::server::ResolvesServerCert>,
target_a: net::Ipv4Addr,
passphrase: String,
http_domain: domain::Name,
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(
task_stack: &mut util::TaskStack<unexpected::Error>,
domain_manager: sync::Arc<dyn domain::manager::Manager>,
cert_resolver: sync::Arc<dyn rustls::server::ResolvesServerCert>,
config: service::Config,
target_a: net::Ipv4Addr,
passphrase: String,
http_listen_addr: net::SocketAddr,
http_domain: domain::Name,
https_params: Option<HTTPSParams>,
) -> 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,
target_a,
passphrase,
http_domain: http_domain.clone(),
handlebars: tpl::get(),
config,
});
task_stack.push_spawn(|canceller| tasks::listen_http(service.clone(), canceller));
task_stack.push_spawn(|canceller| {
tasks::listen_http(
service.clone(),
canceller,
http_listen_addr,
http_domain.clone(),
)
});
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));
if let Some(https_params) = https_params {
task_stack.push_spawn(|canceller| {
tasks::listen_https(
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
@ -193,7 +216,7 @@ impl<'svc> Service {
#[derive(Serialize)]
struct Response {
domain: domain::Name,
config: Option<domain::Domain>,
config: Option<domain::config::Config>,
}
let config = match self.domain_manager.get_config(&args.domain) {
@ -232,7 +255,7 @@ impl<'svc> Service {
challenge_token: String,
}
let config: domain::Domain = match domain_config.try_into() {
let config: domain::config::Config = match domain_config.try_into() {
Ok(Some(config)) => config,
Ok(None) => return self.render_error_page(400, "domain config is required"),
Err(e) => {
@ -249,21 +272,12 @@ 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(
"/domain_init.html",
Response {
domain: args.domain,
flat_config: config.into(),
target_a: target_a,
target_a: self.target_a,
challenge_token: config_hash,
},
)
@ -274,11 +288,11 @@ impl<'svc> Service {
args: DomainSyncArgs,
domain_config: service::util::FlatConfig,
) -> Response<Body> {
if args.passphrase != self.config.passphrase.as_str() {
if args.passphrase != self.passphrase.as_str() {
return self.render_error_page(401, "Incorrect passphrase");
}
let config: domain::Domain = match domain_config.try_into() {
let config: domain::config::Config = match domain_config.try_into() {
Ok(Some(config)) => config,
Ok(None) => return self.render_error_page(400, "domain config is required"),
Err(e) => {
@ -302,7 +316,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::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::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::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::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}")),
};
@ -344,8 +358,8 @@ impl<'svc> Service {
.map(strip_port),
req.uri().host().map(strip_port),
) {
(Some(h), _) if h != self.config.primary_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.http_domain.as_str() => Some(h),
_ => None,
}
.and_then(|h| domain::Name::from_str(h).ok());

View File

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

View File

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