Compare commits

...

8 Commits

Author SHA1 Message Date
Brian Picciano
a917f32f04 Got proxy origin working, more or less 2023-07-16 17:10:13 +02:00
Brian Picciano
9beeffcdcf Revert "Have get_file accept and return structs, which will be easier to extend going forward"
This reverts commit a86020eedf.

Turns out that even with this change proxying won't work properly via
origin::Store, it'll just have to be a special case. I'm keeping the
commit around in case we want this for a later case.
2023-07-16 16:09:37 +02:00
Brian Picciano
4a2ac7460f Initial implementation of proxy module 2023-07-16 16:09:20 +02:00
Brian Picciano
a86020eedf Have get_file accept and return structs, which will be easier to extend going forward 2023-07-16 15:40:20 +02:00
Brian Picciano
c336486f5a Define proxy origin 2023-07-16 15:10:02 +02:00
Brian Picciano
5dd2e756cc Rename domain::Domain to domain::Settings, finished renaming it everywhere 2023-07-16 14:38:48 +02:00
Brian Picciano
5a4ff4ca65 Add secret service.http.form_method field for debugging 2023-07-16 13:55:06 +02:00
Brian Picciano
4483185e75 Add builtin domains to configuration 2023-07-15 19:45:56 +02:00
23 changed files with 526 additions and 263 deletions

View File

@ -3,9 +3,11 @@ origin:
domain:
store_dir_path: /tmp/domani_dev_env/domain
service:
http:
form_method: GET
passphrase: foobar
dns_records:
- type: A
- kind: A
addr: 127.0.0.1
- type: AAAA
- kind: AAAA
addr: ::1

12
Cargo.lock generated
View File

@ -466,6 +466,7 @@ dependencies = [
"hex",
"http",
"hyper",
"hyper-reverse-proxy",
"log",
"mime_guess",
"mockall",
@ -1538,6 +1539,17 @@ dependencies = [
"want",
]
[[package]]
name = "hyper-reverse-proxy"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc1af9b1b483fb9f33bd1cda26b35eacf902f0d116fcf0d56075ea5e5923b935"
dependencies = [
"hyper",
"lazy_static",
"unicase",
]
[[package]]
name = "hyper-rustls"
version = "0.24.1"

View File

@ -44,3 +44,4 @@ env_logger = "0.10.0"
serde_yaml = "0.9.22"
rand = "0.8.5"
reqwest = "0.11.18"
hyper-reverse-proxy = "0.5.1"

View File

@ -53,6 +53,18 @@ domain:
# renewed.
#contact_email: REQUIRED if service.http.https_addr is set
# builtins are domains whose configuration is built into domani. These domains
# are not able to be configured via the web interface, and will be hidden from
# it unless the `public` key is set to true.
#builtins:
# An example built-in domain backed by a git repo.
#example.com:
# kind: git
# url: "https://somewhere.com/some/repo.git"
# branch: main
# public: false
service:
# Passphrase which must be given by users who are configuring new domains via
@ -66,14 +78,14 @@ service:
# A CNAME record with the primary_domain of this server is automatically
# included.
dns_records:
#- type: A
#- kind: A
# addr: 127.0.0.1
#- type: AAAA
#- kind: AAAA
# addr: ::1
# NOTE that the name given here must resolve to the Domani server.
#- type: CNAME
#- kind: CNAME
# name: domain.com
# The domain name which will be used to serve the web interface of Domani. If
@ -126,5 +138,6 @@ Within the shell which opens you can do `cargo run` to start a local instance.
* Support for more backends than just git repositories, including:
* IPFS/IPNS
* Alternative URLs (reverse proxy)
* Small static files (e.g. for well-knowns)
* Google Drive
* Dropbox

View File

@ -18,11 +18,11 @@ 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 struct Settings {
pub origin_descr: origin::Descr,
}
impl Domain {
impl Settings {
pub fn hash(&self) -> Result<String, unexpected::Error> {
let mut h = Sha256::new();
serde_json::to_writer(&mut h, self).or_unexpected()?;

View File

@ -1,7 +1,9 @@
use std::{net, path, str::FromStr};
use std::{collections, net, path, str::FromStr};
use serde::Deserialize;
use crate::domain;
fn default_resolver_addr() -> net::SocketAddr {
net::SocketAddr::from_str("1.1.1.1:53").unwrap()
}
@ -25,10 +27,20 @@ pub struct ConfigACME {
pub contact_email: String,
}
#[derive(Deserialize)]
pub struct BuiltinDomain {
#[serde(flatten)]
pub settings: domain::Settings,
pub public: bool,
}
#[derive(Deserialize)]
pub struct Config {
pub store_dir_path: path::PathBuf,
#[serde(default)]
pub dns: ConfigDNS,
pub acme: Option<ConfigACME>,
#[serde(default)]
pub builtins: collections::HashMap<domain::Name, BuiltinDomain>,
}

View File

@ -7,7 +7,7 @@ use std::sync;
use tokio_util::sync::CancellationToken;
#[derive(thiserror::Error, Debug)]
pub enum GetConfigError {
pub enum GetSettingsError {
#[error("not found")]
NotFound,
@ -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<store::GetError> for GetSettingsError {
fn from(e: store::GetError) -> GetSettingsError {
match e {
store::GetError::NotFound => GetConfigError::NotFound,
store::GetError::Unexpected(e) => GetConfigError::Unexpected(e),
store::GetError::NotFound => GetSettingsError::NotFound,
store::GetError::Unexpected(e) => GetSettingsError::Unexpected(e),
}
}
}
@ -32,6 +32,9 @@ pub enum GetFileError {
#[error("file not found")]
FileNotFound,
#[error("origin is of kind proxy")]
OriginIsProxy { url: String },
#[error(transparent)]
Unexpected(#[from] unexpected::Error),
}
@ -79,7 +82,10 @@ impl From<store::GetError> for SyncError {
}
#[derive(thiserror::Error, Debug)]
pub enum SyncWithConfigError {
pub enum SyncWithSettingsError {
#[error("cannot call SyncWithSettings on builtin domain")]
BuiltinDomain,
#[error("invalid url")]
InvalidURL,
@ -99,35 +105,36 @@ pub enum SyncWithConfigError {
Unexpected(#[from] unexpected::Error),
}
impl From<origin::SyncError> for SyncWithConfigError {
fn from(e: origin::SyncError) -> SyncWithConfigError {
impl From<origin::SyncError> for SyncWithSettingsError {
fn from(e: origin::SyncError) -> SyncWithSettingsError {
match e {
origin::SyncError::InvalidURL => SyncWithConfigError::InvalidURL,
origin::SyncError::InvalidBranchName => SyncWithConfigError::InvalidBranchName,
origin::SyncError::AlreadyInProgress => SyncWithConfigError::AlreadyInProgress,
origin::SyncError::Unexpected(e) => SyncWithConfigError::Unexpected(e),
origin::SyncError::InvalidURL => SyncWithSettingsError::InvalidURL,
origin::SyncError::InvalidBranchName => SyncWithSettingsError::InvalidBranchName,
origin::SyncError::AlreadyInProgress => SyncWithSettingsError::AlreadyInProgress,
origin::SyncError::Unexpected(e) => SyncWithSettingsError::Unexpected(e),
}
}
}
impl From<checker::CheckDomainError> for SyncWithConfigError {
fn from(e: checker::CheckDomainError) -> SyncWithConfigError {
impl From<checker::CheckDomainError> for SyncWithSettingsError {
fn from(e: checker::CheckDomainError) -> SyncWithSettingsError {
match e {
checker::CheckDomainError::ServiceDNSRecordsNotSet => {
SyncWithConfigError::ServiceDNSRecordsNotSet
SyncWithSettingsError::ServiceDNSRecordsNotSet
}
checker::CheckDomainError::ChallengeTokenNotSet => {
SyncWithConfigError::ChallengeTokenNotSet
SyncWithSettingsError::ChallengeTokenNotSet
}
checker::CheckDomainError::Unexpected(e) => SyncWithConfigError::Unexpected(e),
checker::CheckDomainError::Unexpected(e) => SyncWithSettingsError::Unexpected(e),
}
}
}
impl From<store::SetError> for SyncWithConfigError {
fn from(e: store::SetError) -> SyncWithConfigError {
impl From<store::SetError> for SyncWithSettingsError {
fn from(e: store::SetError) -> SyncWithSettingsError {
match e {
store::SetError::Unexpected(e) => SyncWithConfigError::Unexpected(e),
store::SetError::BuiltinDomain => SyncWithSettingsError::BuiltinDomain,
store::SetError::Unexpected(e) => SyncWithSettingsError::Unexpected(e),
}
}
}
@ -136,7 +143,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_settings(&self, domain: &domain::Name) -> Result<domain::Settings, GetSettingsError>;
fn get_file<'store>(
&'store self,
@ -149,11 +156,11 @@ pub trait Manager: Sync + Send + rustls::server::ResolvesServerCert {
domain: domain::Name,
) -> util::BoxFuture<'mgr, Result<(), unexpected::Error>>;
fn sync_with_config<'mgr>(
fn sync_with_settings<'mgr>(
&'mgr self,
domain: domain::Name,
config: domain::Domain,
) -> util::BoxFuture<'mgr, Result<(), SyncWithConfigError>>;
settings: domain::Settings,
) -> util::BoxFuture<'mgr, Result<(), SyncWithSettingsError>>;
fn get_acme_http01_challenge_key(
&self,
@ -231,7 +238,7 @@ impl ManagerImpl {
}
impl Manager for ManagerImpl {
fn get_config(&self, domain: &domain::Name) -> Result<domain::Domain, GetConfigError> {
fn get_settings(&self, domain: &domain::Name) -> Result<domain::Settings, GetSettingsError> {
Ok(self.domain_store.get(domain)?)
}
@ -241,6 +248,11 @@ impl Manager for ManagerImpl {
path: &str,
) -> Result<util::BoxByteStream, GetFileError> {
let config = self.domain_store.get(domain)?;
if let origin::Descr::Proxy { url } = config.origin_descr {
return Err(GetFileError::OriginIsProxy { url });
}
let f = self.origin_store.get_file(&config.origin_descr, path)?;
Ok(f)
}
@ -258,23 +270,21 @@ impl Manager for ManagerImpl {
})
}
fn sync_with_config<'mgr>(
fn sync_with_settings<'mgr>(
&'mgr self,
domain: domain::Name,
config: domain::Domain,
) -> util::BoxFuture<'mgr, Result<(), SyncWithConfigError>> {
settings: domain::Settings,
) -> util::BoxFuture<'mgr, Result<(), SyncWithSettingsError>> {
Box::pin(async move {
let config_hash = config
let hash = settings
.hash()
.or_unexpected_while("calculating config hash")?;
self.domain_checker
.check_domain(&domain, &config_hash)
.await?;
self.domain_checker.check_domain(&domain, &hash).await?;
self.origin_store.sync(&config.origin_descr)?;
self.origin_store.sync(&settings.origin_descr)?;
self.domain_store.set(&domain, &config)?;
self.domain_store.set(&domain, &settings)?;
self.sync_cert(domain).await?;

View File

@ -1,5 +1,5 @@
use std::str::FromStr;
use std::{cmp, fmt};
use std::{cmp, fmt, hash};
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
use trust_dns_client::rr as trust_dns_rr;
@ -7,7 +7,7 @@ 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,
rr: trust_dns_rr::Name,
utf8_str: String,
}
@ -17,7 +17,7 @@ impl Name {
}
pub fn as_rr(&self) -> &trust_dns_rr::Name {
&self.inner
&self.rr
}
}
@ -36,13 +36,21 @@ impl FromStr for Name {
n.set_fqdn(true);
Ok(Name { inner: n, utf8_str })
Ok(Name { rr: n, utf8_str })
}
}
impl cmp::PartialEq for Name {
fn eq(&self, other: &Self) -> bool {
self.inner == other.inner
self.rr == other.rr
}
}
impl cmp::Eq for Name {}
impl hash::Hash for Name {
fn hash<H: hash::Hasher>(&self, state: &mut H) {
self.rr.hash(state);
}
}

View File

@ -1,6 +1,4 @@
use std::path;
use std::str::FromStr;
use std::{fs, io};
use std::{collections, fs, io, path, str::FromStr};
use crate::domain;
use crate::error::unexpected::{self, Intoable, Mappable};
@ -16,14 +14,17 @@ pub enum GetError {
#[derive(thiserror::Error, Debug)]
pub enum SetError {
#[error("cannot call set on builtin domain")]
BuiltinDomain,
#[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 get(&self, domain: &domain::Name) -> Result<domain::Settings, GetError>;
fn set(&self, domain: &domain::Name, settings: &domain::Settings) -> Result<(), SetError>;
fn all_domains(&self) -> Result<Vec<domain::Name>, unexpected::Error>;
}
@ -39,40 +40,40 @@ impl FSStore {
})
}
fn config_dir_path(&self, domain: &domain::Name) -> path::PathBuf {
fn settings_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")
fn settings_file_path(&self, domain: &domain::Name) -> path::PathBuf {
self.settings_dir_path(domain).join("settings.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() {
fn get(&self, domain: &domain::Name) -> Result<domain::Settings, GetError> {
let path = self.settings_file_path(domain);
let settings_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)
let settings = serde_json::from_reader(settings_file)
.map_unexpected_while(|| format!("json parsing {}", path.display()))?;
Ok(config)
Ok(settings)
}
fn set(&self, domain: &domain::Name, config: &domain::Domain) -> Result<(), SetError> {
let dir_path = self.config_dir_path(domain);
fn set(&self, domain: &domain::Name, settings: &domain::Settings) -> Result<(), SetError> {
let dir_path = self.settings_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())
let file_path = self.settings_file_path(domain);
let settings_file = fs::File::create(file_path.as_path())
.map_unexpected_while(|| format!("creating file {}", file_path.display()))?;
serde_json::to_writer(config_file, config)
serde_json::to_writer(settings_file, settings)
.map_unexpected_while(|| format!("writing config to {}", file_path.display()))?;
Ok(())
@ -96,6 +97,51 @@ impl Store for FSStore {
}
}
pub struct StoreWithBuiltin<S: Store> {
inner: S,
domains: collections::HashMap<domain::Name, domain::config::BuiltinDomain>,
}
impl<S: Store> StoreWithBuiltin<S> {
pub fn new(
inner: S,
builtin_domains: collections::HashMap<domain::Name, domain::config::BuiltinDomain>,
) -> StoreWithBuiltin<S> {
StoreWithBuiltin {
inner,
domains: builtin_domains,
}
}
}
impl<S: Store> Store for StoreWithBuiltin<S> {
fn get(&self, domain: &domain::Name) -> Result<domain::Settings, GetError> {
if let Some(domain) = self.domains.get(domain) {
return Ok(domain.settings.clone());
}
self.inner.get(domain)
}
fn set(&self, domain: &domain::Name, settings: &domain::Settings) -> Result<(), SetError> {
if self.domains.get(domain).is_some() {
return Err(SetError::BuiltinDomain);
}
self.inner.set(domain, settings)
}
fn all_domains(&self) -> Result<Vec<domain::Name>, unexpected::Error> {
let inner_domains = self.inner.all_domains()?;
let mut domains: Vec<domain::Name> = self
.domains
.iter()
.filter(|(_, v)| v.public)
.map(|(k, _)| k.clone())
.collect();
domains.extend(inner_domains);
Ok(domains)
}
}
#[cfg(test)]
mod tests {
use super::{Store, *};
@ -108,13 +154,13 @@ mod tests {
#[test]
fn basic() {
let tmp_dir = TempDir::new("domain_config_store").unwrap();
let tmp_dir = TempDir::new("domain_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 {
let settings = domain::Settings {
origin_descr: Descr::Git {
url: "bar".to_string(),
branch_name: "baz".to_string(),
@ -123,20 +169,23 @@ mod tests {
assert!(matches!(
store.get(&domain),
Err::<domain::Domain, GetError>(GetError::NotFound)
Err::<domain::Settings, GetError>(GetError::NotFound)
));
store.set(&domain, &config).expect("config set");
assert_eq!(config, store.get(&domain).expect("config retrieved"));
store.set(&domain, &settings).expect("set");
assert_eq!(settings, store.get(&domain).expect("settings retrieved"));
let new_config = domain::Domain {
let new_settings = domain::Settings {
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"));
store.set(&domain, &new_settings).expect("set");
assert_eq!(
new_settings,
store.get(&domain).expect("settings retrieved")
);
}
}

View File

@ -89,10 +89,13 @@ async fn main() {
.await
.expect("domain checker initialization failed");
let domain_config_store =
let domain_store =
domani::domain::store::FSStore::new(&config.domain.store_dir_path.join("domains"))
.expect("domain config store initialization failed");
let domain_store =
domani::domain::store::StoreWithBuiltin::new(domain_store, config.domain.builtins);
let domain_acme_manager = if config.service.http.https_addr.is_some() {
let acme_config = config
.domain
@ -121,7 +124,7 @@ async fn main() {
let domain_manager = domani::domain::manager::ManagerImpl::new(
&mut task_stack,
origin_store,
domain_config_store,
domain_store,
domain_checker,
domain_acme_manager,
);

View File

@ -2,6 +2,7 @@ mod config;
mod descr;
pub mod git;
pub mod mux;
pub mod proxy;
pub use config::*;
pub use descr::Descr;

View File

@ -3,9 +3,11 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind")]
/// A unique description of an origin, from where a domain might be served.
pub enum Descr {
Git { url: String, branch_name: String },
Proxy { url: String },
}
impl Descr {
@ -24,6 +26,10 @@ impl Descr {
h_update(url);
h_update(branch_name);
}
Descr::Proxy { url } => {
h_update("proxy");
h_update(url);
}
}
h.finalize().encode_hex::<String>()

View File

@ -56,15 +56,24 @@ impl FSStore {
format!("origin/{branch_name}")
}
fn deconstruct_descr(descr: &origin::Descr) -> (&str, &str) {
if let origin::Descr::Git {
ref url,
ref branch_name,
} = descr
{
(url, branch_name)
} else {
panic!("non git descr passed in")
}
}
fn create_repo_snapshot(
&self,
repo: gix::Repository,
descr: &origin::Descr,
) -> Result<RepoSnapshot, CreateRepoSnapshotError> {
let origin::Descr::Git {
ref branch_name, ..
} = descr;
let (_, branch_name) = Self::deconstruct_descr(descr);
let branch_ref = self.branch_ref(branch_name);
let commit_object_id = repo
@ -148,10 +157,7 @@ impl FSStore {
fs::create_dir_all(repo_path)
.map_unexpected_while(|| format!("creating {}", repo_path.display()))?;
let origin::Descr::Git {
ref url,
ref branch_name,
} = descr;
let (url, branch_name) = Self::deconstruct_descr(descr);
let (repo, _) = gix::prepare_clone_bare(url.clone(), repo_path)
.map_err(|e| match e {

50
src/origin/proxy.rs Normal file
View File

@ -0,0 +1,50 @@
use crate::error::unexpected::{self, Mappable};
use std::{net, str::FromStr};
// proxy is a special case because it is so tied to the underlying protocol that a request is
// being served on, it can't be abstracted out into a simple "get_file" operation like other
// origins.
pub async fn serve_http_request(
client_ip: net::IpAddr,
proxy_url: &str,
mut req: hyper::Request<hyper::Body>,
) -> unexpected::Result<hyper::Response<hyper::Body>> {
let parsed_proxy_url =
http::Uri::from_str(proxy_url).or_unexpected_while("parsing proxy url")?;
let scheme = parsed_proxy_url
.scheme()
.or_unexpected_while("expected a scheme of http in the proxy url")?;
if scheme != "http" {
return Err(unexpected::Error::from("proxy url scheme should be 'http"));
}
// figure out what the host header should be, based on the host[:port] of the proxy_url
let host = {
let authority = parsed_proxy_url
.authority()
.or_unexpected_while("getting host from proxy url, there is no host")?;
let host_and_port;
let mut host = authority.host();
if let Some(port) = authority.port() {
host_and_port = format!("{host}:{port}");
host = host_and_port.as_str();
};
http::header::HeaderValue::from_str(host).or_unexpected()?
};
req.headers_mut().insert("host", host);
match hyper_reverse_proxy::call(client_ip, proxy_url, req).await {
Ok(res) => Ok(res),
// ProxyError doesn't actually implement Error :facepalm: so we have to format the error
// manually
Err(e) => Err(unexpected::Error::from(
format!("error while proxying: {e:?}").as_str(),
)),
}
}

View File

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

35
src/service/config.rs Normal file
View File

@ -0,0 +1,35 @@
use crate::{domain, service};
use serde::{Deserialize, Serialize};
use std::{net, str::FromStr};
fn default_primary_domain() -> domain::Name {
domain::Name::from_str("localhost").unwrap()
}
#[derive(Serialize, Deserialize, Clone, PartialEq)]
#[serde(tag = "kind")]
pub enum ConfigDNSRecord {
A { addr: net::Ipv4Addr },
AAAA { addr: net::Ipv6Addr },
CNAME { name: domain::Name },
}
impl From<ConfigDNSRecord> for domain::checker::DNSRecord {
fn from(r: ConfigDNSRecord) -> Self {
match r {
ConfigDNSRecord::A { addr } => Self::A(addr),
ConfigDNSRecord::AAAA { addr } => Self::AAAA(addr),
ConfigDNSRecord::CNAME { name } => Self::CNAME(name),
}
}
}
#[derive(Deserialize)]
pub struct Config {
pub passphrase: String,
pub dns_records: Vec<ConfigDNSRecord>,
#[serde(default = "default_primary_domain")]
pub primary_domain: domain::Name,
#[serde(default)]
pub http: service::http::Config,
}

View File

@ -4,14 +4,15 @@ mod tpl;
pub use config::*;
use http::request::Parts;
use hyper::{Body, Method, Request, Response};
use serde::{Deserialize, Serialize};
use std::str::FromStr;
use std::{future, sync};
use std::{future, net, sync};
use crate::error::unexpected;
use crate::{domain, service, util};
use crate::{domain, origin, service, util};
pub struct Service {
domain_manager: sync::Arc<dyn domain::manager::Manager>,
@ -48,6 +49,7 @@ pub fn new(
#[derive(Serialize)]
struct BasePresenter<'a, T> {
page_name: &'a str,
form_method: &'a str,
data: T,
}
@ -59,12 +61,18 @@ struct DomainGetArgs {
#[derive(Deserialize)]
struct DomainInitArgs {
domain: domain::Name,
#[serde(flatten)]
flat_domain_settings: service::util::FlatDomainSettings,
}
#[derive(Deserialize)]
struct DomainSyncArgs {
domain: domain::Name,
passphrase: String,
#[serde(flatten)]
flat_domain_settings: service::util::FlatDomainSettings,
}
impl<'svc> Service {
@ -121,6 +129,7 @@ impl<'svc> Service {
"/base.html",
BasePresenter {
page_name: "/error.html",
form_method: self.config.http.form_method.as_str(),
data: &Response { error_msg: e },
},
)
@ -143,13 +152,20 @@ impl<'svc> Service {
"/base.html",
BasePresenter {
page_name: name,
form_method: self.config.http.form_method.as_str(),
data,
},
)
}
fn serve_origin(&self, domain: domain::Name, path: &str) -> Response<Body> {
async fn serve_origin(
&self,
client_ip: net::IpAddr,
domain: domain::Name,
req: Request<Body>,
) -> Response<Body> {
let mut path_owned;
let path = req.uri().path();
let path = match path.ends_with('/') {
true => {
@ -168,20 +184,46 @@ impl<'svc> Service {
Err(domain::manager::GetFileError::FileNotFound) => {
self.render_error_page(404, "File not found")
}
Err(domain::manager::GetFileError::OriginIsProxy { url }) => {
origin::proxy::serve_http_request(client_ip, &url, req)
.await
.unwrap_or_else(|e| {
self.internal_error(format!("proxying {domain} to {url}: {e}").as_str())
})
}
Err(domain::manager::GetFileError::Unexpected(e)) => {
self.internal_error(format!("failed to fetch file {path}: {e}").as_str())
}
}
}
async fn with_query_req<'a, F, In, Out>(&self, req: &'a Request<Body>, f: F) -> Response<Body>
async fn with_query_req<'a, F, In, Out>(
&self,
req: &'a Parts,
body: Body,
f: F,
) -> Response<Body>
where
In: Deserialize<'a>,
In: for<'d> Deserialize<'d>,
F: FnOnce(In) -> Out,
Out: future::Future<Output = Response<Body>>,
{
let query = req.uri().query().unwrap_or("");
match serde_urlencoded::from_str::<In>(query) {
let res = match self.config.http.form_method {
ConfigFormMethod::GET => {
serde_urlencoded::from_str::<In>(req.uri.query().unwrap_or(""))
}
ConfigFormMethod::POST => {
let body = match hyper::body::to_bytes(body).await {
Ok(bytes) => bytes.to_vec(),
Err(e) => {
return self.internal_error(format!("failed to read body: {e}").as_str())
}
};
serde_urlencoded::from_bytes::<In>(body.as_ref())
}
};
match res {
Ok(args) => f(args).await,
Err(err) => {
self.render_error_page(400, format!("failed to parse query args: {}", err).as_str())
@ -191,43 +233,35 @@ impl<'svc> Service {
fn domain_get(&self, args: DomainGetArgs) -> Response<Body> {
#[derive(Serialize)]
struct Response {
struct Data {
domain: domain::Name,
config: Option<domain::Domain>,
settings: Option<domain::Settings>,
}
let config = match self.domain_manager.get_config(&args.domain) {
Ok(config) => Some(config),
Err(domain::manager::GetConfigError::NotFound) => None,
Err(domain::manager::GetConfigError::Unexpected(e)) => {
let settings = match self.domain_manager.get_settings(&args.domain) {
Ok(settings) => Some(settings),
Err(domain::manager::GetSettingsError::NotFound) => None,
Err(domain::manager::GetSettingsError::Unexpected(e)) => {
return self.internal_error(
format!(
"retrieving configuration for domain {}: {}",
&args.domain, e
)
.as_str(),
format!("retrieving settings for domain {}: {}", &args.domain, e).as_str(),
);
}
};
self.render_page(
"/domain.html",
Response {
Data {
domain: args.domain,
config,
settings,
},
)
}
fn domain_init(
&self,
args: DomainInitArgs,
domain_config: service::util::FlatConfig,
) -> Response<Body> {
fn domain_init(&self, args: DomainInitArgs) -> Response<Body> {
#[derive(Serialize)]
struct Response<'a> {
struct Data<'a> {
domain: domain::Name,
flat_config: service::util::FlatConfig,
flat_domain_settings: service::util::FlatDomainSettings,
dns_records: &'a [service::ConfigDNSRecord],
challenge_token: String,
@ -235,19 +269,19 @@ impl<'svc> Service {
dns_records_have_cname: bool,
}
let config: domain::Domain = match domain_config.try_into() {
Ok(Some(config)) => config,
Ok(None) => return self.render_error_page(400, "domain config is required"),
let settings: domain::Settings = match args.flat_domain_settings.try_into() {
Ok(settings) => settings,
Err(e) => {
return self.render_error_page(400, format!("invalid domain config: {e}").as_str())
return self
.render_error_page(400, format!("invalid domain settings: {e}").as_str())
}
};
let config_hash = match config.hash() {
let settings_hash = match settings.hash() {
Ok(hash) => hash,
Err(e) => {
return self.internal_error(
format!("failed to hash domain config {config:?}: {e}").as_str(),
format!("failed to hash domain settings {settings:?}: {e}").as_str(),
)
}
};
@ -258,13 +292,21 @@ impl<'svc> Service {
_ => false,
});
let flat_domain_settings = match settings.try_into() {
Ok(s) => s,
Err(e) => {
return self
.internal_error(format!("failed to flatten domains settings: {e}").as_str())
}
};
self.render_page(
"/domain_init.html",
Response {
Data {
domain: args.domain,
flat_config: config.into(),
flat_domain_settings,
dns_records: &self.config.dns_records,
challenge_token: config_hash,
challenge_token: settings_hash,
domain_is_zone_apex,
dns_records_have_cname,
@ -272,50 +314,48 @@ impl<'svc> Service {
)
}
async fn domain_sync(
&self,
args: DomainSyncArgs,
domain_config: service::util::FlatConfig,
) -> Response<Body> {
async fn domain_sync(&self, args: DomainSyncArgs) -> Response<Body> {
if args.passphrase != self.config.passphrase.as_str() {
return self.render_error_page(401, "Incorrect passphrase");
}
let config: domain::Domain = match domain_config.try_into() {
Ok(Some(config)) => config,
Ok(None) => return self.render_error_page(400, "domain config is required"),
let settings: domain::Settings = match args.flat_domain_settings.try_into() {
Ok(settings) => settings,
Err(e) => {
return self.render_error_page(400, format!("invalid domain config: {e}").as_str())
return self
.render_error_page(400, format!("invalid domain settings: {e}").as_str())
}
};
let sync_result = self
.domain_manager
.sync_with_config(args.domain.clone(), config)
.sync_with_settings(args.domain.clone(), settings)
.await;
#[derive(Serialize)]
struct Response {
struct Data {
domain: domain::Name,
error_msg: Option<String>,
}
let error_msg = match sync_result {
Ok(_) => None,
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::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::SyncWithSettingsError::BuiltinDomain) => Some("This domain is not able to be configured, please contact the server administrator.".to_string()),
Err(domain::manager::SyncWithSettingsError::InvalidURL) => Some("Fetching the git repository failed, please double check that you input the correct URL.".to_string()),
Err(domain::manager::SyncWithSettingsError::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::SyncWithSettingsError::AlreadyInProgress) => Some("The configuration of your domain is still in progress, please refresh in a few minutes.".to_string()),
Err(domain::manager::SyncWithSettingsError::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::SyncWithSettingsError::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::SyncWithSettingsError::Unexpected(e)) => Some(format!("An unexpected error occurred: {e}")),
};
let response = Response {
domain: args.domain,
error_msg,
};
self.render_page("/domain_sync.html", response)
self.render_page(
"/domain_sync.html",
Data {
domain: args.domain,
error_msg,
},
)
}
fn domains(&self) -> Response<Body> {
@ -339,7 +379,7 @@ impl<'svc> Service {
self.render_page("/domains.html", Response { domains })
}
async fn handle_request(&self, req: Request<Body>) -> Response<Body> {
async fn handle_request(&self, client_ip: net::IpAddr, req: Request<Body>) -> Response<Body> {
let maybe_host = match (
req.headers()
.get("Host")
@ -353,33 +393,34 @@ impl<'svc> Service {
}
.and_then(|h| domain::Name::from_str(h).ok());
let method = req.method();
let path = req.uri().path();
{
let path = req.uri().path();
// Serving acme challenges always takes priority. We serve them from the same store no
// matter the domain, presumably they are cryptographically random enough that it doesn't
// matter.
if method == Method::GET && path.starts_with("/.well-known/acme-challenge/") {
let token = path.trim_start_matches("/.well-known/acme-challenge/");
// Serving acme challenges always takes priority. We serve them from the same store no
// matter the domain, presumably they are cryptographically random enough that it doesn't
// matter.
if req.method() == Method::GET && path.starts_with("/.well-known/acme-challenge/") {
let token = path.trim_start_matches("/.well-known/acme-challenge/");
if let Ok(key) = self.domain_manager.get_acme_http01_challenge_key(token) {
return self.serve(200, "token.txt", key.into());
if let Ok(key) = self.domain_manager.get_acme_http01_challenge_key(token) {
return self.serve(200, "token.txt", key.into());
}
}
}
// Serving domani challenges similarly takes priority.
if method == Method::GET && path == "/.well-known/domani-challenge" {
if let Some(ref domain) = maybe_host {
match self
.domain_manager
.get_domain_checker_challenge_token(domain)
{
Ok(Some(token)) => return self.serve(200, "token.txt", token.into()),
Ok(None) => return self.render_error_page(404, "Token not found"),
Err(e) => {
return self.internal_error(
format!("failed to get token for domain {}: {e}", domain).as_str(),
)
// Serving domani challenges similarly takes priority.
if req.method() == Method::GET && path == "/.well-known/domani-challenge" {
if let Some(ref domain) = maybe_host {
match self
.domain_manager
.get_domain_checker_challenge_token(domain)
{
Ok(Some(token)) => return self.serve(200, "token.txt", token.into()),
Ok(None) => return self.render_error_page(404, "Token not found"),
Err(e) => {
return self.internal_error(
format!("failed to get token for domain {}: {e}", domain).as_str(),
)
}
}
}
}
@ -387,38 +428,38 @@ impl<'svc> Service {
// If a managed domain was given then serve that from its origin
if let Some(domain) = maybe_host {
return self.serve_origin(domain, req.uri().path());
return self.serve_origin(client_ip, domain, req).await;
}
// Serve main domani site
let (req, body) = req.into_parts();
let path = req.uri.path();
if method == Method::GET && path.starts_with("/static/") {
if req.method == Method::GET && path.starts_with("/static/") {
return self.render(200, path, ());
}
match (method, path) {
let config_form_method = self.config.http.form_method.as_ref();
match (&req.method, path) {
(&Method::GET, "/") | (&Method::GET, "/index.html") => {
self.render_page("/index.html", ())
}
(&Method::GET, "/domain.html") => {
self.with_query_req(&req, |args: DomainGetArgs| async { self.domain_get(args) })
.await
}
(&Method::GET, "/domain_init.html") => {
self.with_query_req(&req, |args: DomainInitArgs| async {
self.with_query_req(&req, |config: service::util::FlatConfig| async {
self.domain_init(args, config)
})
.await
(form_method, "/domain.html") if form_method == config_form_method => {
self.with_query_req(&req, body, |args: DomainGetArgs| async {
self.domain_get(args)
})
.await
}
(&Method::GET, "/domain_sync.html") => {
self.with_query_req(&req, |args: DomainSyncArgs| async {
self.with_query_req(&req, |config: service::util::FlatConfig| async {
self.domain_sync(args, config).await
})
.await
(form_method, "/domain_init.html") if form_method == config_form_method => {
self.with_query_req(&req, body, |args: DomainInitArgs| async {
self.domain_init(args)
})
.await
}
(form_method, "/domain_sync.html") if form_method == config_form_method => {
self.with_query_req(&req, body, |args: DomainSyncArgs| async {
self.domain_sync(args).await
})
.await
}

View File

@ -5,11 +5,44 @@ fn default_http_addr() -> net::SocketAddr {
net::SocketAddr::from_str("[::]:3030").unwrap()
}
#[derive(Deserialize)]
pub enum ConfigFormMethod {
GET,
POST,
}
impl ConfigFormMethod {
pub fn as_str(&self) -> &str {
match self {
Self::GET => "GET",
Self::POST => "POST",
}
}
}
impl Default for ConfigFormMethod {
fn default() -> Self {
Self::POST
}
}
impl AsRef<hyper::Method> for ConfigFormMethod {
fn as_ref(&self) -> &hyper::Method {
match self {
Self::GET => &hyper::Method::GET,
Self::POST => &hyper::Method::POST,
}
}
}
#[derive(Deserialize)]
pub struct Config {
#[serde(default = "default_http_addr")]
pub http_addr: net::SocketAddr,
pub https_addr: Option<net::SocketAddr>,
#[serde(default)]
pub form_method: ConfigFormMethod,
}
impl Default for Config {
@ -17,6 +50,7 @@ impl Default for Config {
Self {
http_addr: default_http_addr(),
https_addr: None,
form_method: Default::default(),
}
}
}

View File

@ -4,6 +4,8 @@ use crate::service;
use std::{convert, future, sync};
use futures::StreamExt;
use hyper::server::conn::AddrStream;
use tokio_rustls::server::TlsStream;
use tokio_util::sync::CancellationToken;
pub async fn listen_http(
@ -13,13 +15,14 @@ pub async fn listen_http(
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 |conn: &AddrStream| {
let service = service.clone();
let client_ip = conn.remote_addr().ip();
// Create a `Service` for responding to the request.
let hyper_service = hyper::service::service_fn(move |req| {
let service = service.clone();
async move { Ok::<_, convert::Infallible>(service.handle_request(req).await) }
async move { Ok::<_, convert::Infallible>(service.handle_request(client_ip, req).await) }
});
// Return the service to hyper.
@ -48,13 +51,14 @@ pub async fn listen_https(
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 |conn: &TlsStream<AddrStream>| {
let service = service.clone();
let client_ip = conn.get_ref().0.remote_addr().ip();
// Create a `Service` for responding to the request.
let hyper_service = hyper::service::service_fn(move |req| {
let service = service.clone();
async move { Ok::<_, convert::Infallible>(service.handle_request(req).await) }
async move { Ok::<_, convert::Infallible>(service.handle_request(client_ip, req).await) }
});
// Return the service to hyper.

View File

@ -2,7 +2,7 @@
Configure New Domain
</h2>
{{# if data.config }}
{{# if data.settings }}
<p>Your domain <code>{{ data.domain }}</code> is already configured with
Domani. You can see the existing configuration below. If you modify any values
@ -20,7 +20,7 @@ automatically updated too!</p>
<p><em>In the future Domani will support more backends than just git
repos.</em></p>
<form method="GET" action="/domain_init.html">
<form method="{{ form_method }}" action="/domain_init.html">
<input name="domain" type="hidden" value="{{ data.domain }}" />
<input name="config_origin_descr_kind" type="hidden" value="git" />
@ -32,7 +32,7 @@ automatically updated too!</p>
<input name="config_origin_descr_git_url"
type="text"
placeholder="https://example.com/some_repo.git"
value="{{ data.config.origin_descr.Git.url }}"
value="{{ data.settings.origin_descr.Git.url }}"
required />
</label>
</p>
@ -43,7 +43,7 @@ automatically updated too!</p>
<input name="config_origin_descr_git_branch_name"
type="text"
placeholder="main / master / etc..."
value="{{ data.config.origin_descr.Git.branch_name }}"
value="{{ data.settings.origin_descr.Git.branch_name }}"
required />
</label>
</p>

View File

@ -3,9 +3,9 @@
<p>This step requires a passphrase that has been given to you by the
administrator of the Domani server:</p>
<form method="GET" action="/domain_sync.html" id="syncForm">
<form method="{{ form_method }}" action="/domain_sync.html" id="syncForm">
<input name="domain" type="hidden" value="{{ data.domain }}" />
{{ #each data.flat_config }}
{{ #each data.flat_domain_settings }}
<input name="{{ @key }}" type="hidden" value="{{ this }}" />
{{ /each }}
@ -47,7 +47,7 @@ query for your domain name. It can be <strong>one or more of</strong>:</p>
{{ #each data.dns_records }}
<tr>
<td>{{ this.type }}</td>
<td>{{ this.kind }}</td>
<td>{{ lookup ../data "domain" }}</td>
{{ #if this.name }}
<td>{{ this.name }}</td>

View File

@ -13,7 +13,7 @@ server, and you're done!</p>
<p>Input your domain name below to set it up, or to reconfigure it has already
been set up.</p>
<form method="get" action="/domain.html">
<form method="{{ form_method }}" action="/domain.html">
<fieldset>
<label>

View File

@ -1,49 +1,58 @@
use std::convert::{From, TryFrom};
use std::convert::TryFrom;
use serde::{Deserialize, Serialize};
use crate::{domain, origin};
use crate::{domain, error::unexpected, origin};
#[derive(Serialize, Deserialize)]
pub struct FlatConfig {
config_origin_descr_kind: Option<String>,
config_origin_descr_git_url: Option<String>,
config_origin_descr_git_branch_name: Option<String>,
#[derive(Serialize, Deserialize, Default)]
pub struct FlatDomainSettings {
domain_setting_origin_descr_kind: Option<String>,
domain_setting_origin_descr_git_url: Option<String>,
domain_setting_origin_descr_git_branch_name: Option<String>,
domain_setting_origin_descr_proxy_url: Option<String>,
}
impl TryFrom<FlatConfig> for Option<domain::Domain> {
impl TryFrom<FlatDomainSettings> for domain::Settings {
type Error = String;
fn try_from(v: FlatConfig) -> Result<Self, Self::Error> {
match v
.config_origin_descr_kind
fn try_from(v: FlatDomainSettings) -> Result<Self, Self::Error> {
let origin_descr = match v
.domain_setting_origin_descr_kind
.unwrap_or("".to_string())
.as_str()
{
"" => Ok(None),
"git" => Ok(Some(domain::Domain {
origin_descr: origin::Descr::Git {
url: v
.config_origin_descr_git_url
.ok_or("config_origin_descr_git_url missing")?,
branch_name: v
.config_origin_descr_git_branch_name
.ok_or("config_origin_descr_git_branch_name missing")?,
},
})),
_ => Err("invalid config_origin_descr_kind".to_string()),
}
"git" => Ok(origin::Descr::Git {
url: v
.domain_setting_origin_descr_git_url
.ok_or("missing domain_setting_origin_descr_git_url")?,
branch_name: v
.domain_setting_origin_descr_git_branch_name
.ok_or("missing domain_setting_origin_descr_git_branch_name")?,
}),
"" => Err("missing domain_setting_origin_descr_kind".to_string()),
_ => Err("invalid domain_setting_origin_descr_kind".to_string()),
}?;
Ok(Self { origin_descr })
}
}
impl From<domain::Domain> for FlatConfig {
fn from(v: domain::Domain) -> Self {
impl TryFrom<domain::Settings> for FlatDomainSettings {
type Error = unexpected::Error;
fn try_from(v: domain::Settings) -> Result<Self, Self::Error> {
match v.origin_descr {
origin::Descr::Git { url, branch_name } => FlatConfig {
config_origin_descr_kind: Some("git".to_string()),
config_origin_descr_git_url: Some(url),
config_origin_descr_git_branch_name: Some(branch_name),
},
origin::Descr::Git { url, branch_name } => Ok(FlatDomainSettings {
domain_setting_origin_descr_kind: Some("git".to_string()),
domain_setting_origin_descr_git_url: Some(url),
domain_setting_origin_descr_git_branch_name: Some(branch_name),
..Default::default()
}),
origin::Descr::Proxy { .. } => Err(unexpected::Error::from(
"proxy origins not supported for forms",
)),
}
}
}