Compare commits

..

5 Commits

Author SHA1 Message Date
Brian Picciano
63f4975d5a Fix deserialization of FlatDomainSettings boolean flags 2023-07-18 19:05:51 +02:00
Brian Picciano
ccd2285b11 Prevent users from seeing proxy config in web interface 2023-07-18 19:00:37 +02:00
Brian Picciano
87c779ebb6 Fix rendering on domain.html 2023-07-18 18:31:36 +02:00
Brian Picciano
7049252787 Support serve_protocols field on domain settings 2023-07-17 20:22:22 +02:00
Brian Picciano
5099f79260 Add ability to add request headers to proxied requests 2023-07-17 16:54:03 +02:00
12 changed files with 272 additions and 102 deletions

View File

@ -5,7 +5,14 @@ domain:
builtins: builtins:
foo: foo:
kind: proxy kind: proxy
url: http://ok url: http://127.0.0.1:9000
request_http_headers:
- name: x-foo
value: BAR
- name: host
value: hi
- name: user-agent
value: ""
bar: bar:
kind: git kind: git
url: a url: a

View File

@ -64,16 +64,39 @@ domain:
# url: "https://somewhere.com/some/repo.git" # url: "https://somewhere.com/some/repo.git"
# branch_name: main # branch_name: main
# public: false # public: false
#
# # Which protocols to serve the domain on. The given list overwrites the
# # default, which is to serve on all available protocols.
# #serve_protocols:
# #- protocol: http
# #- protocol: https
# An example built-in domain backed by a reverse-proxy to some other # An example built-in domain backed by a reverse-proxy to some other
# web-service. Proxies are currently limited in the following ways: # web-service. Requests to the backing service will automatically have
# X-Forwarded-For and (if HTTPS) X-Forwarded-Proto headers added to them.
#
# Proxies are currently limited in the following ways:
# * url must be to an http endpoint (not https) # * url must be to an http endpoint (not https)
# * dns.resolver_addr is ignored and the system-wide dns is used # * dns.resolver_addr is ignored and the system-wide dns is used
# #
#proxy.example.com: #proxy.example.com:
# kind: proxy # kind: proxy
# url: "http://some.other.service.com" # url: "http://some.other.service.com"
#
# # Extra headers to add to requests being proxied
# request_http_headers:
# - name: Host
# value: "yet.another.service.com"
# - name: X-HEADER-TO-DELETE
# value: ""
#
# public: false # public: false
#
# # Which protocols to serve the domain on. The given list overwrites the
# # default, which is to serve on all available protocols.
# #serve_protocols:
# #- protocol: http
# #- protocol: https
service: service:

View File

@ -3,30 +3,9 @@ pub mod checker;
mod config; mod config;
pub mod manager; pub mod manager;
mod name; mod name;
mod settings;
pub mod store; pub mod store;
pub use config::*; pub use config::*;
pub use name::*; pub use name::*;
pub use settings::*;
use crate::error::unexpected::{self, Mappable};
use crate::origin;
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 Settings {
#[serde(flatten)]
pub origin_descr: origin::Descr,
}
impl Settings {
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>())
}
}

View File

@ -6,6 +6,8 @@ use crate::util;
use std::sync; use std::sync;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
pub type GetSettingsResult = domain::store::GetResult;
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum GetSettingsError { pub enum GetSettingsError {
#[error("not found")] #[error("not found")]
@ -140,7 +142,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_settings(&self, domain: &domain::Name) -> Result<domain::Settings, GetSettingsError>; fn get_settings(&self, domain: &domain::Name) -> Result<GetSettingsResult, GetSettingsError>;
fn get_file<'store>( fn get_file<'store>(
&'store self, &'store self,
@ -235,7 +237,7 @@ impl ManagerImpl {
} }
impl Manager for ManagerImpl { impl Manager for ManagerImpl {
fn get_settings(&self, domain: &domain::Name) -> Result<domain::Settings, GetSettingsError> { fn get_settings(&self, domain: &domain::Name) -> Result<GetSettingsResult, GetSettingsError> {
Ok(self.domain_store.get(domain)?) Ok(self.domain_store.get(domain)?)
} }
@ -244,13 +246,13 @@ 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_store.get(domain)?; let settings = self.domain_store.get(domain)?.settings;
if let origin::Descr::Proxy { .. } = config.origin_descr { if let origin::Descr::Proxy { .. } = settings.origin_descr {
return Err(unexpected::Error::from("origin is proxy, can't serve file").into()); return Err(unexpected::Error::from("origin is proxy, can't serve file").into());
} }
let f = self.origin_store.get_file(&config.origin_descr, path)?; let f = self.origin_store.get_file(&settings.origin_descr, path)?;
Ok(f) Ok(f)
} }

38
src/domain/settings.rs Normal file
View File

@ -0,0 +1,38 @@
use crate::error::unexpected::{self, Mappable};
use crate::origin;
use hex::ToHex;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)]
#[serde(tag = "protocol")]
pub enum SettingsServeProtocol {
#[serde(rename = "http")]
Http,
#[serde(rename = "https")]
Https,
}
fn default_serve_protocols() -> Vec<SettingsServeProtocol> {
vec![SettingsServeProtocol::Http, SettingsServeProtocol::Https]
}
#[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 Settings {
#[serde(flatten)]
pub origin_descr: origin::Descr,
#[serde(default = "default_serve_protocols")]
pub serve_protocols: Vec<SettingsServeProtocol>,
}
impl Settings {
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>())
}
}

View File

@ -3,6 +3,13 @@ use std::{collections, fs, io, path, str::FromStr};
use crate::domain; use crate::domain;
use crate::error::unexpected::{self, Intoable, Mappable}; use crate::error::unexpected::{self, Intoable, Mappable};
#[derive(Debug, PartialEq)]
pub struct GetResult {
pub settings: domain::Settings,
pub builtin: bool,
pub public: bool,
}
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum GetError { pub enum GetError {
#[error("not found")] #[error("not found")]
@ -23,7 +30,7 @@ pub enum SetError {
#[mockall::automock] #[mockall::automock]
pub trait Store { pub trait Store {
fn get(&self, domain: &domain::Name) -> Result<domain::Settings, GetError>; fn get(&self, domain: &domain::Name) -> Result<GetResult, GetError>;
fn set(&self, domain: &domain::Name, settings: &domain::Settings) -> Result<(), SetError>; fn set(&self, domain: &domain::Name, settings: &domain::Settings) -> Result<(), SetError>;
fn all_domains(&self) -> Result<Vec<domain::Name>, unexpected::Error>; fn all_domains(&self) -> Result<Vec<domain::Name>, unexpected::Error>;
} }
@ -50,7 +57,7 @@ impl FSStore {
} }
impl Store for FSStore { impl Store for FSStore {
fn get(&self, domain: &domain::Name) -> Result<domain::Settings, GetError> { fn get(&self, domain: &domain::Name) -> Result<GetResult, GetError> {
let path = self.settings_file_path(domain); let path = self.settings_file_path(domain);
let settings_file = fs::File::open(path.as_path()).map_err(|e| match e.kind() { let settings_file = fs::File::open(path.as_path()).map_err(|e| match e.kind() {
io::ErrorKind::NotFound => GetError::NotFound, io::ErrorKind::NotFound => GetError::NotFound,
@ -61,7 +68,12 @@ impl Store for FSStore {
let settings = serde_json::from_reader(settings_file) let settings = serde_json::from_reader(settings_file)
.map_unexpected_while(|| format!("json parsing {}", path.display()))?; .map_unexpected_while(|| format!("json parsing {}", path.display()))?;
Ok(settings)
Ok(GetResult {
settings,
public: true,
builtin: false,
})
} }
fn set(&self, domain: &domain::Name, settings: &domain::Settings) -> Result<(), SetError> { fn set(&self, domain: &domain::Name, settings: &domain::Settings) -> Result<(), SetError> {
@ -115,9 +127,13 @@ impl<S: Store> StoreWithBuiltin<S> {
} }
impl<S: Store> Store for StoreWithBuiltin<S> { impl<S: Store> Store for StoreWithBuiltin<S> {
fn get(&self, domain: &domain::Name) -> Result<domain::Settings, GetError> { fn get(&self, domain: &domain::Name) -> Result<GetResult, GetError> {
if let Some(domain) = self.domains.get(domain) { if let Some(domain) = self.domains.get(domain) {
return Ok(domain.settings.clone()); return Ok(GetResult {
settings: domain.settings.clone(),
public: domain.public,
builtin: true,
});
} }
self.inner.get(domain) self.inner.get(domain)
} }
@ -165,26 +181,39 @@ mod tests {
url: "bar".to_string(), url: "bar".to_string(),
branch_name: "baz".to_string(), branch_name: "baz".to_string(),
}, },
serve_protocols: vec![domain::SettingsServeProtocol::Http],
}; };
assert!(matches!( assert!(matches!(
store.get(&domain), store.get(&domain),
Err::<domain::Settings, GetError>(GetError::NotFound) Err::<GetResult, GetError>(GetError::NotFound)
)); ));
store.set(&domain, &settings).expect("set"); store.set(&domain, &settings).expect("set");
assert_eq!(settings, store.get(&domain).expect("settings retrieved")); assert_eq!(
GetResult {
settings,
public: true,
builtin: false,
},
store.get(&domain).expect("settings retrieved")
);
let new_settings = domain::Settings { let new_settings = domain::Settings {
origin_descr: Descr::Git { origin_descr: Descr::Git {
url: "BAR".to_string(), url: "BAR".to_string(),
branch_name: "BAZ".to_string(), branch_name: "BAZ".to_string(),
}, },
serve_protocols: vec![],
}; };
store.set(&domain, &new_settings).expect("set"); store.set(&domain, &new_settings).expect("set");
assert_eq!( assert_eq!(
new_settings, GetResult {
settings: new_settings,
public: true,
builtin: false,
},
store.get(&domain).expect("settings retrieved") store.get(&domain).expect("settings retrieved")
); );
} }

View File

@ -79,7 +79,9 @@ async fn main() {
} }
for (domain, builtin_domain) in &config.domain.builtins { for (domain, builtin_domain) in &config.domain.builtins {
if let domani::origin::Descr::Proxy { ref url } = builtin_domain.settings.origin_descr { if let domani::origin::Descr::Proxy { ref url, .. } =
builtin_domain.settings.origin_descr
{
if let Err(e) = domani::origin::proxy::validate_proxy_url(url) { if let Err(e) = domani::origin::proxy::validate_proxy_url(url) {
panic!("invalid config for builtin domain {domain}: {e}"); panic!("invalid config for builtin domain {domain}: {e}");
} }

View File

@ -2,6 +2,12 @@ use hex::ToHex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct DescrProxyHttpHeader {
pub name: String,
pub value: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind")] #[serde(tag = "kind")]
/// A unique description of an origin, from where a domain might be served. /// A unique description of an origin, from where a domain might be served.
@ -10,7 +16,10 @@ pub enum Descr {
Git { url: String, branch_name: String }, Git { url: String, branch_name: String },
#[serde(rename = "proxy")] #[serde(rename = "proxy")]
Proxy { url: String }, Proxy {
url: String,
request_http_headers: Vec<DescrProxyHttpHeader>,
},
} }
impl Descr { impl Descr {
@ -29,9 +38,17 @@ impl Descr {
h_update(url); h_update(url);
h_update(branch_name); h_update(branch_name);
} }
Descr::Proxy { url } => { Descr::Proxy {
url,
request_http_headers,
} => {
h_update("proxy"); h_update("proxy");
h_update(url); h_update(url);
for h in request_http_headers {
h_update("header");
h_update(&h.name);
h_update(&h.value);
}
} }
} }

View File

@ -1,6 +1,6 @@
use crate::error::unexpected::{self, Mappable}; use crate::error::unexpected::{self, Mappable};
use crate::{domain, origin}; use crate::{domain, origin};
use http::header::HeaderValue; use http::header::{HeaderName, HeaderValue};
use std::{net, str::FromStr}; use std::{net, str::FromStr};
// proxy is a special case because it is so tied to the underlying protocol that a request is // proxy is a special case because it is so tied to the underlying protocol that a request is
@ -30,33 +30,37 @@ pub async fn serve_http_request(
mut req: hyper::Request<hyper::Body>, mut req: hyper::Request<hyper::Body>,
req_is_https: bool, req_is_https: bool,
) -> unexpected::Result<hyper::Response<hyper::Body>> { ) -> unexpected::Result<hyper::Response<hyper::Body>> {
let proxy_url = if let origin::Descr::Proxy { ref url } = settings.origin_descr { let (proxy_url, request_http_headers) = if let origin::Descr::Proxy {
url ref url,
ref request_http_headers,
} = settings.origin_descr
{
(url, request_http_headers)
} else { } else {
panic!("non-proxy domain settings passed in: {settings:?}") panic!("non-proxy domain settings passed in: {settings:?}")
}; };
let parsed_proxy_url = for header in request_http_headers {
http::Uri::from_str(proxy_url).or_unexpected_while("parsing proxy url {proxy_url}")?; let name: HeaderName = header
.name
.as_str()
.try_into()
.map_unexpected_while(|| format!("parsing header name {}", &header.name))?;
// figure out what the host header should be, based on the host[:port] of the proxy_url if header.value == "" {
let host = { req.headers_mut().remove(name);
let authority = parsed_proxy_url.authority().or_unexpected_while(format!( continue;
"getting host from proxy url {proxy_url}, there is no host" }
))?;
let host_and_port; let value = HeaderValue::from_str(&header.value).map_unexpected_while(|| {
let mut host = authority.host(); format!(
"parsing {} as value for header {}",
&header.value, &header.name
)
})?;
if let Some(port) = authority.port() { req.headers_mut().insert(name, value);
host_and_port = format!("{host}:{port}"); }
host = host_and_port.as_str();
};
HeaderValue::from_str(host).or_unexpected()?
};
req.headers_mut().insert("host", host);
if req_is_https { if req_is_https {
req.headers_mut() req.headers_mut()

View File

@ -165,23 +165,8 @@ impl<'svc> Service {
req: Request<Body>, req: Request<Body>,
req_is_https: bool, req_is_https: bool,
) -> Response<Body> { ) -> Response<Body> {
// first check if the domain is backed by a proxy, and deal with that first let settings = match self.domain_manager.get_settings(&domain) {
match self.domain_manager.get_settings(&domain) { Ok(domain::manager::GetSettingsResult { settings, .. }) => settings,
Ok(settings) => {
if let origin::Descr::Proxy { .. } = settings.origin_descr {
return origin::proxy::serve_http_request(
&settings,
client_ip,
req,
req_is_https,
)
.await
.unwrap_or_else(|e| {
self.internal_error(format!("proxying {domain}: {e}").as_str())
});
}
// fall out of match
}
Err(domain::manager::GetSettingsError::NotFound) => { Err(domain::manager::GetSettingsError::NotFound) => {
return self.render_error_page(404, "Domain not found"); return self.render_error_page(404, "Domain not found");
} }
@ -190,8 +175,29 @@ impl<'svc> Service {
format!("failed to fetch settings for domain {domain}: {e}").as_str(), format!("failed to fetch settings for domain {domain}: {e}").as_str(),
); );
} }
};
let allowed = settings.serve_protocols.iter().any(|p| match p {
domain::SettingsServeProtocol::Http => !req_is_https,
domain::SettingsServeProtocol::Https => req_is_https,
});
if !allowed {
return self.render_error_page(
421,
"The requested protocol is not supported by this domain",
);
} }
// if the domain is backed by a proxy then that is handled specially.
if let origin::Descr::Proxy { .. } = settings.origin_descr {
return origin::proxy::serve_http_request(&settings, client_ip, req, req_is_https)
.await
.unwrap_or_else(|e| {
self.internal_error(format!("proxying {domain}: {e}").as_str())
});
};
let mut path_owned; let mut path_owned;
let path = req.uri().path(); let path = req.uri().path();
@ -260,7 +266,24 @@ impl<'svc> Service {
} }
let settings = match self.domain_manager.get_settings(&args.domain) { let settings = match self.domain_manager.get_settings(&args.domain) {
Ok(settings) => Some(settings), Ok(domain::manager::GetSettingsResult {
settings,
public,
builtin,
}) => match (public, builtin) {
(false, _) => None,
(true, false) => Some(settings),
(true, true) => {
return self.render_error_page(
403,
format!(
"Settings for domain {} cannot be viewed or modified",
args.domain
)
.as_str(),
)
}
},
Err(domain::manager::GetSettingsError::NotFound) => None, Err(domain::manager::GetSettingsError::NotFound) => None,
Err(domain::manager::GetSettingsError::Unexpected(e)) => { Err(domain::manager::GetSettingsError::Unexpected(e)) => {
return self.internal_error( return self.internal_error(

View File

@ -22,17 +22,17 @@ automatically updated too!</p>
<form method="{{ form_method }}" action="/domain_init.html"> <form method="{{ form_method }}" action="/domain_init.html">
<input name="domain" type="hidden" value="{{ data.domain }}" /> <input name="domain" type="hidden" value="{{ data.domain }}" />
<input name="config_origin_descr_kind" type="hidden" value="git" /> <input name="domain_setting_origin_descr_kind" type="hidden" value="git" />
<fieldset> <fieldset>
<legend>Git Repository</legend> <legend>Git Repository</legend>
<p> <p>
<label> <label>
URL (HTTPS only): URL (HTTPS only):
<input name="config_origin_descr_git_url" <input name="domain_setting_origin_descr_git_url"
type="text" type="text"
placeholder="https://example.com/some_repo.git" placeholder="https://example.com/some_repo.git"
value="{{ data.settings.origin_descr.Git.url }}" value="{{ data.settings.url }}"
required /> required />
</label> </label>
</p> </p>
@ -40,10 +40,10 @@ automatically updated too!</p>
<p> <p>
<label> <label>
Branch name: Branch name:
<input name="config_origin_descr_git_branch_name" <input name="domain_setting_origin_descr_git_branch_name"
type="text" type="text"
placeholder="main / master / etc..." placeholder="main / master / etc..."
value="{{ data.settings.origin_descr.Git.branch_name }}" value="{{ data.settings.branch_name }}"
required /> required />
</label> </label>
</p> </p>

View File

@ -1,28 +1,45 @@
use std::convert::TryFrom; use std::convert::TryFrom;
use serde::{Deserialize, Serialize}; use serde::{de, Deserialize, Serialize};
use crate::{domain, error::unexpected, origin}; use crate::{domain, error::unexpected, origin};
fn deserialize_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
where
D: de::Deserializer<'de>,
{
let s: &str = de::Deserialize::deserialize(deserializer)?;
match s {
"true" => Ok(true),
"false" => Ok(false),
_ => Err(de::Error::unknown_variant(s, &["true", "false"])),
}
}
#[derive(Serialize, Deserialize, Default)] #[derive(Serialize, Deserialize, Default)]
pub struct FlatDomainSettings { pub struct FlatDomainSettings {
domain_setting_origin_descr_kind: Option<String>, domain_setting_origin_descr_kind: String,
domain_setting_origin_descr_git_url: Option<String>, domain_setting_origin_descr_git_url: Option<String>,
domain_setting_origin_descr_git_branch_name: Option<String>, domain_setting_origin_descr_git_branch_name: Option<String>,
domain_setting_origin_descr_proxy_url: Option<String>, domain_setting_origin_descr_proxy_url: Option<String>,
#[serde(default)]
#[serde(deserialize_with = "deserialize_bool")]
domain_setting_serve_protocol_http: bool,
#[serde(default)]
#[serde(deserialize_with = "deserialize_bool")]
domain_setting_serve_protocol_https: bool,
} }
impl TryFrom<FlatDomainSettings> for domain::Settings { impl TryFrom<FlatDomainSettings> for domain::Settings {
type Error = String; type Error = String;
fn try_from(v: FlatDomainSettings) -> Result<Self, Self::Error> { fn try_from(v: FlatDomainSettings) -> Result<Self, Self::Error> {
let origin_descr = match v let origin_descr = match v.domain_setting_origin_descr_kind.as_str() {
.domain_setting_origin_descr_kind
.unwrap_or("".to_string())
.as_str()
{
"git" => Ok(origin::Descr::Git { "git" => Ok(origin::Descr::Git {
url: v url: v
.domain_setting_origin_descr_git_url .domain_setting_origin_descr_git_url
@ -35,7 +52,20 @@ impl TryFrom<FlatDomainSettings> for domain::Settings {
_ => Err("invalid domain_setting_origin_descr_kind".to_string()), _ => Err("invalid domain_setting_origin_descr_kind".to_string()),
}?; }?;
Ok(Self { origin_descr }) let mut serve_protocols = Vec::<domain::SettingsServeProtocol>::default();
if v.domain_setting_serve_protocol_http {
serve_protocols.push(domain::SettingsServeProtocol::Http);
}
if v.domain_setting_serve_protocol_https {
serve_protocols.push(domain::SettingsServeProtocol::Https);
}
Ok(Self {
origin_descr,
serve_protocols,
})
} }
} }
@ -43,16 +73,32 @@ impl TryFrom<domain::Settings> for FlatDomainSettings {
type Error = unexpected::Error; type Error = unexpected::Error;
fn try_from(v: domain::Settings) -> Result<Self, Self::Error> { fn try_from(v: domain::Settings) -> Result<Self, Self::Error> {
let mut res = FlatDomainSettings::default();
match v.origin_descr { match v.origin_descr {
origin::Descr::Git { url, branch_name } => Ok(FlatDomainSettings { origin::Descr::Git { url, branch_name } => {
domain_setting_origin_descr_kind: Some("git".to_string()), res.domain_setting_origin_descr_kind = "git".to_string();
domain_setting_origin_descr_git_url: Some(url), res.domain_setting_origin_descr_git_url = Some(url);
domain_setting_origin_descr_git_branch_name: Some(branch_name), res.domain_setting_origin_descr_git_branch_name = Some(branch_name);
..Default::default() }
}), origin::Descr::Proxy { .. } => {
origin::Descr::Proxy { .. } => Err(unexpected::Error::from( return Err(unexpected::Error::from(
"proxy origins not supported for forms", "proxy origins not supported for forms",
)), ));
}
} }
for serve_protocol in v.serve_protocols {
match serve_protocol {
domain::SettingsServeProtocol::Http => {
res.domain_setting_serve_protocol_http = true
}
domain::SettingsServeProtocol::Https => {
res.domain_setting_serve_protocol_https = true
}
}
}
Ok(res)
} }
} }