wip initial state w/ not working certs
This commit is contained in:
commit
5b9dcd6a60
|
@ -0,0 +1,4 @@
|
||||||
|
/target
|
||||||
|
salut.toml
|
||||||
|
.vscode
|
||||||
|
input.txt
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,9 @@
|
||||||
|
[workspace]
|
||||||
|
|
||||||
|
members = ["salut", "desec"]
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
lto = "fat"
|
||||||
|
opt-level = 3
|
||||||
|
strip = "symbols"
|
||||||
|
panic = "abort"
|
|
@ -0,0 +1,20 @@
|
||||||
|
[package]
|
||||||
|
name = "desec"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# minreq = { version = "2.8.1", features = [
|
||||||
|
# "punycode",
|
||||||
|
# "https",
|
||||||
|
# "urlencoding",
|
||||||
|
# "json-using-serde",
|
||||||
|
# ] }
|
||||||
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
|
anyhow = "1"
|
||||||
|
serde_json = "1.0"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
tokio = { version = "1.0", features = ["full"] }
|
||||||
|
enum-display = "0.1.3"
|
|
@ -0,0 +1,71 @@
|
||||||
|
use enum_display::EnumDisplay;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug, Clone, Copy, EnumDisplay)]
|
||||||
|
pub enum Record {
|
||||||
|
TXT,
|
||||||
|
A,
|
||||||
|
AAAA,
|
||||||
|
MX,
|
||||||
|
ANY,
|
||||||
|
CAA,
|
||||||
|
CNAME,
|
||||||
|
DNSKEY,
|
||||||
|
DS,
|
||||||
|
NS,
|
||||||
|
PTR,
|
||||||
|
SOA,
|
||||||
|
SRV,
|
||||||
|
TLSA,
|
||||||
|
TSIG,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug, Clone)]
|
||||||
|
pub struct RRSet {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub record: Record,
|
||||||
|
pub domain: String,
|
||||||
|
#[serde(deserialize_with = "empty_string_as_none")]
|
||||||
|
pub subname: Option<String>,
|
||||||
|
pub name: String,
|
||||||
|
pub ttl: i32,
|
||||||
|
pub records: Vec<String>,
|
||||||
|
pub created: String,
|
||||||
|
pub touched: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
use serde::de::IntoDeserializer;
|
||||||
|
|
||||||
|
fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
T: serde::Deserialize<'de>,
|
||||||
|
{
|
||||||
|
let opt = Option::<String>::deserialize(de)?;
|
||||||
|
let opt = opt.as_ref().map(String::as_str);
|
||||||
|
match opt {
|
||||||
|
None | Some("") => Ok(None),
|
||||||
|
Some(s) => T::deserialize(s.into_deserializer()).map(Some),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Serialize, Debug, Clone, Default)]
|
||||||
|
pub struct RRSetPatch {
|
||||||
|
#[serde(alias = "type")]
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub record: Option<Record>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub domain: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub subname: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ttl: Option<i32>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub records: Option<Vec<String>>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub created: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub touched: Option<String>,
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
|
pub struct Domain {
|
||||||
|
pub keys: Option<Vec<Key>>,
|
||||||
|
pub created: String,
|
||||||
|
pub published: String,
|
||||||
|
pub touched: String,
|
||||||
|
pub name: String,
|
||||||
|
pub minimum_ttl: i32,
|
||||||
|
pub zonefile: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug, Clone)]
|
||||||
|
pub struct Key {
|
||||||
|
pub dnskey: String,
|
||||||
|
pub ds: Vec<String>,
|
||||||
|
pub managed: bool,
|
||||||
|
}
|
|
@ -0,0 +1,205 @@
|
||||||
|
pub mod dns;
|
||||||
|
pub mod domains;
|
||||||
|
|
||||||
|
use dns::{RRSet, RRSetPatch, Record};
|
||||||
|
use domains::Domain;
|
||||||
|
use reqwest::{Client, Method, RequestBuilder};
|
||||||
|
use serde::{de::DeserializeOwned, Deserialize, Serialize};
|
||||||
|
|
||||||
|
const BASE_URL: &str = "https://desec.io/api/v1/";
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Session {
|
||||||
|
client: Client,
|
||||||
|
login: Login,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Clone)]
|
||||||
|
struct Login {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct Req<'a> {
|
||||||
|
email: &'a str,
|
||||||
|
password: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum DeError {
|
||||||
|
RequestError(reqwest::Error),
|
||||||
|
InvalidStatus(u16, Option<String>),
|
||||||
|
InvalidCredentials,
|
||||||
|
InvalidToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for DeError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
DeError::RequestError(err) => write!(f, "request error: {err}"),
|
||||||
|
DeError::InvalidStatus(code, content) => {
|
||||||
|
write!(f, "Unexpected status code [{code}], body: {:?}", content)
|
||||||
|
}
|
||||||
|
DeError::InvalidCredentials => write!(f, "invalid credentials"),
|
||||||
|
DeError::InvalidToken => write!(f, "invalid or expired token"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for DeError {}
|
||||||
|
|
||||||
|
impl From<reqwest::Error> for DeError {
|
||||||
|
fn from(value: reqwest::Error) -> Self {
|
||||||
|
Self::RequestError(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result<T> = std::result::Result<T, DeError>;
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
pub async fn login(email: &str, password: &str) -> Result<Self> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client
|
||||||
|
.post(format!("{BASE_URL}/auth/login/"))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.json(&Req { email, password })
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match response.status().as_u16() {
|
||||||
|
200 => Ok(Self {
|
||||||
|
client,
|
||||||
|
login: response.json().await?,
|
||||||
|
}),
|
||||||
|
403 => Err(DeError::InvalidCredentials),
|
||||||
|
code => Err(DeError::InvalidStatus(code, response.text().await.ok())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_token(token: String) -> Self {
|
||||||
|
Self {
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
login: Login { token },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn authorized_query<D>(&self, method: Method, path: &str) -> Result<D>
|
||||||
|
where
|
||||||
|
D: DeserializeOwned,
|
||||||
|
{
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.request(method, format!("{BASE_URL}/{path}/"))
|
||||||
|
.header("Authorization", format!("Token {}", &self.login.token))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match response.status().as_u16() {
|
||||||
|
200 => Ok(response.json().await?),
|
||||||
|
403 => Err(DeError::InvalidToken),
|
||||||
|
code => Err(DeError::InvalidStatus(code, response.text().await.ok())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn authorized_exchange<R, D>(&self, method: Method, path: &str, item: &R) -> Result<D>
|
||||||
|
where
|
||||||
|
R: Serialize,
|
||||||
|
D: DeserializeOwned,
|
||||||
|
{
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.request(method, format!("{BASE_URL}/{path}/"))
|
||||||
|
.header("Authorization", format!("Token {}", &self.login.token))
|
||||||
|
.json(item)
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match response.status().as_u16() {
|
||||||
|
200 | 201 => Ok(response.json().await?),
|
||||||
|
403 => Err(DeError::InvalidToken),
|
||||||
|
code => Err(DeError::InvalidStatus(code, response.text().await.ok())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn authorized_get_query<D, Q>(&self, path: &str, query: Option<Q>) -> Result<D>
|
||||||
|
where
|
||||||
|
D: DeserializeOwned,
|
||||||
|
Q: Serialize,
|
||||||
|
{
|
||||||
|
let mut request: RequestBuilder = self
|
||||||
|
.client
|
||||||
|
.get(format!("{BASE_URL}/{path}/"))
|
||||||
|
.header("Authorization", format!("Token {}", &self.login.token));
|
||||||
|
if let Some(query) = query {
|
||||||
|
request = request.query(&query);
|
||||||
|
}
|
||||||
|
let response = request.send().await?;
|
||||||
|
|
||||||
|
match response.status().as_u16() {
|
||||||
|
200 => Ok(response.json().await?),
|
||||||
|
403 => Err(DeError::InvalidToken),
|
||||||
|
code => Err(DeError::InvalidStatus(code, response.text().await.ok())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_domains(&self) -> Result<Vec<Domain>> {
|
||||||
|
self.authorized_query(Method::GET, "domains").await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_domain(&self, domain: &str) -> Result<Domain> {
|
||||||
|
self.authorized_query(Method::GET, &format!("domains/{domain}"))
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_rrsets(
|
||||||
|
&self,
|
||||||
|
domain: &str,
|
||||||
|
type_filter: Option<Vec<Record>>,
|
||||||
|
) -> Result<Vec<RRSet>> {
|
||||||
|
self.authorized_get_query::<Vec<RRSet>, Vec<(&str, Record)>>(
|
||||||
|
&format!("domains/{domain}/rrsets"),
|
||||||
|
type_filter.map(|f| f.into_iter().map(|rec| ("type", rec)).collect()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn modify_rrset(&self, rrset: RRSet, patch: RRSetPatch) -> Result<RRSet> {
|
||||||
|
self.authorized_exchange(Method::PATCH, &rrset_url(&rrset), &patch)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_rrset(&self, rrset: RRSet) -> Result<RRSet> {
|
||||||
|
let mut rrset = rrset;
|
||||||
|
rrset.subname = Some(rrset.subname.unwrap_or(String::new()));
|
||||||
|
|
||||||
|
self.authorized_exchange(
|
||||||
|
Method::POST,
|
||||||
|
&format!("domains/{}/rrsets", rrset.domain),
|
||||||
|
&rrset,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_rrset(&self, rrset: RRSet) -> Result<()> {
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.delete(format!("{BASE_URL}/{}/", rrset_url(&rrset)))
|
||||||
|
.header("Authorization", format!("Token {}", &self.login.token))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
match response.status().as_u16() {
|
||||||
|
204 => Ok(()),
|
||||||
|
403 => Err(DeError::InvalidToken),
|
||||||
|
code => Err(DeError::InvalidStatus(code, response.text().await.ok())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rrset_url(rrset: &RRSet) -> String {
|
||||||
|
format!(
|
||||||
|
"domains/{}/rrsets/{}/{}",
|
||||||
|
rrset.domain,
|
||||||
|
rrset.subname.clone().unwrap_or("...".into()),
|
||||||
|
rrset.record
|
||||||
|
)
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,22 @@
|
||||||
|
[package]
|
||||||
|
name = "salut"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
quick-xml = { version = "0.29", features = ["async-tokio"] }
|
||||||
|
enum-display = "0.1.3"
|
||||||
|
anyhow = "1"
|
||||||
|
log = { version = "0.4" }
|
||||||
|
config_struct = { version = "0.5.0", features = ["toml-parsing"] }
|
||||||
|
toml = "0.7.5"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
pretty_env_logger = "0.5.0"
|
||||||
|
async-trait = "0.1.68"
|
||||||
|
tokio-rustls = { version = "0.24.1" }
|
||||||
|
instant-acme = "0.3.2"
|
||||||
|
desec = { path = "../desec" }
|
||||||
|
rcgen = "0.11.1"
|
|
@ -0,0 +1,298 @@
|
||||||
|
use std::{
|
||||||
|
fs::File,
|
||||||
|
io::{prelude::Write, Read},
|
||||||
|
time::Duration,
|
||||||
|
vec,
|
||||||
|
};
|
||||||
|
|
||||||
|
use desec::dns::{RRSet, RRSetPatch, Record};
|
||||||
|
use instant_acme::{
|
||||||
|
Account, AuthorizationStatus, ChallengeType, Identifier, LetsEncrypt, NewAccount, NewOrder,
|
||||||
|
OrderStatus,
|
||||||
|
};
|
||||||
|
use log::{debug, error, info, warn};
|
||||||
|
use rcgen::{Certificate, CertificateParams, DistinguishedName};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub enum CertStore {
|
||||||
|
Provision,
|
||||||
|
Existing(CertificatePEM),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub domain: String,
|
||||||
|
pub subdomain: Option<String>,
|
||||||
|
pub port: u16,
|
||||||
|
pub cert_store: CertStore,
|
||||||
|
pub desec_cfg: DesecConfig,
|
||||||
|
#[serde(skip)]
|
||||||
|
original_path: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DesecConfig {
|
||||||
|
pub username: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CertificatePEM {
|
||||||
|
pub cert_chain_pem: String,
|
||||||
|
pub private_key_pem: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIG_PATHS: [&str; 3] = [DEFAULT_PATH, "/etc/salut.toml", "/usr/local/etc/salut.toml"];
|
||||||
|
pub const DEFAULT_PATH: &str = "salut.toml";
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
domain: String::new(),
|
||||||
|
subdomain: Some(String::new()),
|
||||||
|
port: 5222,
|
||||||
|
cert_store: CertStore::Provision,
|
||||||
|
desec_cfg: DesecConfig {
|
||||||
|
username: String::new(),
|
||||||
|
password: String::new(),
|
||||||
|
},
|
||||||
|
original_path: DEFAULT_PATH,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
pub fn load() -> Result<Self, anyhow::Error> {
|
||||||
|
for path in CONFIG_PATHS {
|
||||||
|
if let Ok(mut file) = File::open(path) {
|
||||||
|
let mut cfg = String::new();
|
||||||
|
file.read_to_string(&mut cfg)?;
|
||||||
|
let mut cfg: Self = toml::from_str(&cfg)?;
|
||||||
|
cfg.original_path = path;
|
||||||
|
return Ok(cfg);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Err(anyhow::anyhow!(
|
||||||
|
"could not find salut.toml in {CONFIG_PATHS:?}"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self, path: &str) -> Result<(), anyhow::Error> {
|
||||||
|
Ok(write!(
|
||||||
|
File::create(path)?,
|
||||||
|
"{}",
|
||||||
|
toml::to_string_pretty(self)?
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hostname(&self) -> String {
|
||||||
|
match &self.subdomain {
|
||||||
|
Some(sub) => format!("{sub}.{}", &self.domain),
|
||||||
|
None => self.domain.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACME_PREFIX: &str = "_acme-challenge";
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
// Returns existing certificate or provisions a new one via DNS challenge using DeSEC
|
||||||
|
pub async fn certificate(&self) -> Result<CertificatePEM, anyhow::Error> {
|
||||||
|
let desec_cfg = match self.cert_store.clone() {
|
||||||
|
CertStore::Provision => self.desec_cfg.clone(),
|
||||||
|
CertStore::Existing(existing) => return Ok(existing),
|
||||||
|
};
|
||||||
|
|
||||||
|
let account = Account::create(
|
||||||
|
&NewAccount {
|
||||||
|
contact: &["mailto:emilis@puff.place"],
|
||||||
|
terms_of_service_agreed: true,
|
||||||
|
only_return_existing: false,
|
||||||
|
},
|
||||||
|
LetsEncrypt::Staging.url(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let identifier = Identifier::Dns(self.hostname());
|
||||||
|
let mut order = account
|
||||||
|
.new_order(&NewOrder {
|
||||||
|
identifiers: &[identifier],
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
debug!("order url: {}", order.url());
|
||||||
|
let state = order.state();
|
||||||
|
info!("cert order state: {:#?}", state);
|
||||||
|
assert!(matches!(state.status, OrderStatus::Pending));
|
||||||
|
|
||||||
|
debug!("logging into desec as <{}>", &desec_cfg.username);
|
||||||
|
let dns = desec::Session::login(&desec_cfg.username, &desec_cfg.password).await?;
|
||||||
|
debug!("querying existing TXT records");
|
||||||
|
let existing_records: Vec<RRSet> = dns
|
||||||
|
.get_rrsets(&self.domain, Some(vec![Record::TXT]))
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|rec| {
|
||||||
|
if let Some(sub) = &rec.subname {
|
||||||
|
sub.starts_with(ACME_PREFIX)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
debug!(
|
||||||
|
"got {} existing DNS TXT records that match {ACME_PREFIX}",
|
||||||
|
existing_records.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
let authorizations = order.authorizations().await.unwrap();
|
||||||
|
debug!("got {} authorizations for this order", authorizations.len());
|
||||||
|
let mut challenges = Vec::with_capacity(authorizations.len());
|
||||||
|
let mut cleanup_records: Vec<RRSet> = vec![];
|
||||||
|
|
||||||
|
for authz in &authorizations {
|
||||||
|
if let AuthorizationStatus::Valid = authz.status {
|
||||||
|
debug!("Valid authorization, skipping: {authz:?}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We'll use the DNS challenges for this example, but you could
|
||||||
|
// pick something else to use here.
|
||||||
|
let challenge = authz
|
||||||
|
.challenges
|
||||||
|
.iter()
|
||||||
|
.find(|c| c.r#type == ChallengeType::Dns01)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("no dns01 challenge found"))?;
|
||||||
|
|
||||||
|
let Identifier::Dns(identifier) = &authz.identifier;
|
||||||
|
let dns_challenge = order.key_authorization(challenge).dns_value();
|
||||||
|
let subname = format!(
|
||||||
|
"{ACME_PREFIX}{}",
|
||||||
|
match &self.subdomain {
|
||||||
|
Some(sub) => ".".to_owned() + sub,
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
debug!("challenge for {identifier} ready: {dns_challenge}");
|
||||||
|
|
||||||
|
let record = if let Some(id) = (&existing_records)
|
||||||
|
.into_iter()
|
||||||
|
.find(|r| *r.subname.as_ref().unwrap() == subname)
|
||||||
|
{
|
||||||
|
debug!("modifying existing record: {}", id.name);
|
||||||
|
dns.modify_rrset(
|
||||||
|
id.clone(),
|
||||||
|
RRSetPatch {
|
||||||
|
name: Some(self.hostname()),
|
||||||
|
subname: Some(subname),
|
||||||
|
records: Some(vec![format!("\"{dns_challenge}\"")]),
|
||||||
|
..RRSetPatch::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
id.clone()
|
||||||
|
} else {
|
||||||
|
let record = RRSet {
|
||||||
|
record: Record::TXT,
|
||||||
|
domain: self.domain.clone(),
|
||||||
|
name: self.hostname(),
|
||||||
|
subname: Some(subname),
|
||||||
|
ttl: 3600,
|
||||||
|
records: vec![format!("\"{dns_challenge}\"")],
|
||||||
|
created: String::new(),
|
||||||
|
touched: String::new(),
|
||||||
|
};
|
||||||
|
debug!("creating new record: {record:?}");
|
||||||
|
dns.create_rrset(record).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
challenges.push((identifier, &challenge.url));
|
||||||
|
cleanup_records.push(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let the server know we're ready to accept the challenges.
|
||||||
|
debug!("done setting challenges, notifying CA");
|
||||||
|
for (_, url) in &challenges {
|
||||||
|
order.set_challenge_ready(url).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tries = 1u8;
|
||||||
|
let mut delay = Duration::from_millis(250);
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(delay).await;
|
||||||
|
let state = order.refresh().await?;
|
||||||
|
if let OrderStatus::Ready | OrderStatus::Invalid = state.status {
|
||||||
|
info!("order state: {:#?}", state);
|
||||||
|
std::io::stdin().read_line(&mut String::new()).unwrap();
|
||||||
|
break;
|
||||||
|
} else {
|
||||||
|
info!("waiting on order... state: {:?}", state.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
delay *= 2;
|
||||||
|
tries += 1;
|
||||||
|
match tries < 5 {
|
||||||
|
true => info!("[{state:?}({tries})] order is not ready, waiting {delay:?}"),
|
||||||
|
false => {
|
||||||
|
info!("[{state:?}({tries})] order is not ready");
|
||||||
|
return Err(anyhow::anyhow!("order is not ready"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let state = order.state();
|
||||||
|
if state.status != OrderStatus::Ready {
|
||||||
|
for rec in cleanup_records {
|
||||||
|
warn!("cleaning up record: {}", rec.name);
|
||||||
|
if let Err(err) = dns.delete_rrset(rec).await {
|
||||||
|
error!("failed cleaning up record: {err}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"unexpected order status: {:?}\nwith state:{state:#?}",
|
||||||
|
state.status
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for rec in cleanup_records {
|
||||||
|
if let Err(err) = dns.delete_rrset(rec).await {
|
||||||
|
error!("failed cleaning up record: {err}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut names = Vec::with_capacity(challenges.len());
|
||||||
|
for (identifier, _) in challenges {
|
||||||
|
names.push(identifier.to_owned());
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the order is ready, we can provision the certificate.
|
||||||
|
// Use the rcgen library to create a Certificate Signing Request.
|
||||||
|
|
||||||
|
let mut params = CertificateParams::new(names.clone());
|
||||||
|
params.distinguished_name = DistinguishedName::new();
|
||||||
|
let cert = Certificate::from_params(params).unwrap();
|
||||||
|
let csr = cert.serialize_request_der()?;
|
||||||
|
|
||||||
|
// Finalize the order and update config
|
||||||
|
order.finalize(&csr).await.unwrap();
|
||||||
|
let cert_chain_pem = loop {
|
||||||
|
match order.certificate().await.unwrap() {
|
||||||
|
Some(cert_chain_pem) => break cert_chain_pem,
|
||||||
|
None => tokio::time::sleep(Duration::from_secs(1)).await,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let cert = CertificatePEM {
|
||||||
|
cert_chain_pem,
|
||||||
|
private_key_pem: cert.serialize_private_key_pem(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut new_cfg = self.clone();
|
||||||
|
new_cfg.cert_store = CertStore::Existing(cert.clone());
|
||||||
|
new_cfg.save(self.original_path)?;
|
||||||
|
|
||||||
|
Ok(cert)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,155 @@
|
||||||
|
use enum_display::EnumDisplay;
|
||||||
|
use std::string::FromUtf8Error;
|
||||||
|
|
||||||
|
use log::error;
|
||||||
|
use quick_xml::events::attributes::AttrError;
|
||||||
|
|
||||||
|
#[derive(Debug, EnumDisplay, Clone, Copy)]
|
||||||
|
#[enum_display(case = "Kebab")]
|
||||||
|
#[allow(unused)]
|
||||||
|
pub enum StreamError {
|
||||||
|
/// The entity has sent XML that cannot be processed.
|
||||||
|
BadFormat,
|
||||||
|
/// The entity has sent a namespace prefix that is unsupported, or has
|
||||||
|
/// sent no namespace prefix on an element that needs such a prefix
|
||||||
|
BadNamespacePrefix,
|
||||||
|
/// The server either (1) is closing the existing stream for this entity
|
||||||
|
/// because a new stream has been initiated that conflicts with the
|
||||||
|
/// existing stream, or (2) is refusing a new stream for this entity
|
||||||
|
/// because allowing the new stream would conflict with an existing stream
|
||||||
|
Conflict,
|
||||||
|
/// One party is closing the stream because it has reason to believe that
|
||||||
|
/// the other party has permanently lost the ability to communicate over
|
||||||
|
/// the stream
|
||||||
|
ConnectionTimeout,
|
||||||
|
/// The value of the 'to' attribute provided in the initial stream header
|
||||||
|
/// corresponds to an FQDN that is no longer serviced by the receiving entity
|
||||||
|
HostGone,
|
||||||
|
/// The value of the 'to' attribute provided in the initial stream header
|
||||||
|
/// does not correspond to an FQDN that is serviced by the receiving entity
|
||||||
|
HostUnknown,
|
||||||
|
/// A stanza sent between two servers lacks a 'to' or 'from' attribute,
|
||||||
|
/// the 'from' or 'to' attribute has no value, or the value violates the
|
||||||
|
/// rules for XMPP addresses
|
||||||
|
ImproperAddressing,
|
||||||
|
/// The server has experienced a misconfiguration or other internal error
|
||||||
|
/// that prevents it from servicing the stream
|
||||||
|
InternalServerError,
|
||||||
|
/// The data provided in a 'from' attribute does not match an authorized
|
||||||
|
/// JID or validated domain as negotiated (1) between two servers using
|
||||||
|
/// SASL or Server Dialback, or (2) between a client and a server via
|
||||||
|
/// SASL authentication and resource binding
|
||||||
|
InvalidFrom,
|
||||||
|
/// The stream namespace name is something other than
|
||||||
|
/// "http://etherx.jabber.org/streams" or the content
|
||||||
|
/// namespace declared as the default namespace is not supported
|
||||||
|
/// (e.g., something other than "jabber:client" or "jabber:server").
|
||||||
|
InvalidNamespace,
|
||||||
|
/// The entity has sent invalid XML over the stream to a server that
|
||||||
|
/// performs validation
|
||||||
|
InvalidXml,
|
||||||
|
/// The entity has attempted to send XML stanzas or other outbound data
|
||||||
|
/// before the stream has been authenticated, or otherwise is not
|
||||||
|
/// authorized to perform an action related to stream negotiation; the
|
||||||
|
/// receiving entity MUST NOT process the offending data before sending
|
||||||
|
/// the stream error.
|
||||||
|
NotAuthorized,
|
||||||
|
/// The initiating entity has sent XML that violates the well-formedness
|
||||||
|
/// rules of [XML](http://www.w3.org/TR/2008/REC-xml-20081126) or [XML-NAMES](http://www.w3.org/TR/2008/REC-xml-20081126).
|
||||||
|
NotWellFormed,
|
||||||
|
/// The entity has violated some local service policy (e.g., a stanza
|
||||||
|
/// exceeds a configured size limit); the server MAY choose to specify
|
||||||
|
/// the policy in the <text/> element or in an application-specific
|
||||||
|
/// condition element
|
||||||
|
PolicyViolation,
|
||||||
|
/// The server is unable to properly connect to a remote entity that is
|
||||||
|
/// needed for authentication or authorization.
|
||||||
|
/// This condition is not to be used when the cause of the error is within the
|
||||||
|
/// administrative domain of the XMPP service provider, in which case the
|
||||||
|
/// [`StreamError::InternalServerError`] condition is more appropriate.
|
||||||
|
RemoteConnectionFailed,
|
||||||
|
/// The server is closing the stream because it has new (typically security-critical)
|
||||||
|
/// features to offer, because the keys or
|
||||||
|
/// certificates used to establish a secure context for the stream have
|
||||||
|
/// expired or have been revoked during the life of the stream
|
||||||
|
/// because the TLS sequence number has wrapped, etc.
|
||||||
|
/// The reset applies to the stream and to any
|
||||||
|
/// security context established for that stream (e.g., via TLS and SASL),
|
||||||
|
/// which means that encryption and authentication need to be
|
||||||
|
/// negotiated again for the new stream (e.g., TLS session resumption cannot be used)
|
||||||
|
Reset,
|
||||||
|
/// The server lacks the system resources necessary to service the stream
|
||||||
|
ResourceConstraint,
|
||||||
|
/// The entity has attempted to send restricted XML features such as a
|
||||||
|
/// comment, processing instruction, DTD subset, or XML entity reference
|
||||||
|
RestrictedXml,
|
||||||
|
/// The server will not provide service to the initiating entity but is
|
||||||
|
/// redirecting traffic to another host under the administrative control
|
||||||
|
/// of the same service provider. The XML character data of the <see-
|
||||||
|
/// other-host/> element returned by the server MUST specify the
|
||||||
|
/// alternate FQDN or IP address at which to connect, which MUST be a
|
||||||
|
/// valid domainpart or a domainpart plus port number (separated by the
|
||||||
|
/// ':' character in the form "domainpart:port"). If the domainpart is
|
||||||
|
/// the same as the source domain, derived domain, or resolved IPv4 or
|
||||||
|
/// IPv6 address to which the initiating entity originally connected
|
||||||
|
/// (differing only by the port number), then the initiating entity
|
||||||
|
/// SHOULD simply attempt to reconnect at that address.
|
||||||
|
/// (The format of an IPv6 address MUST follow [IPv6-ADDR](https://www.rfc-editor.org/rfc/rfc5952),
|
||||||
|
/// which includes the enclosing the IPv6 address in square brackets '[' and ']'
|
||||||
|
/// as originally defined by [URI](https://www.rfc-editor.org/rfc/rfc3986).)
|
||||||
|
/// Otherwise, the initiating entity MUST resolve the FQDN
|
||||||
|
/// specified in the <see-other-host/> element
|
||||||
|
SeeOtherHost,
|
||||||
|
/// The server is being shut down and all active streams are being closed
|
||||||
|
SystemShutdown,
|
||||||
|
/// The error condition is not one of those defined by the other
|
||||||
|
/// conditions in this list; this error condition SHOULD NOT be used
|
||||||
|
/// except in conjunction with an application-specific condition.
|
||||||
|
UndefinedCondition,
|
||||||
|
/// The initiating entity has encoded the stream in an encoding that is
|
||||||
|
/// not supported by the server (see Section 11.6) or has otherwise
|
||||||
|
/// improperly encoded the stream (e.g., by violating the rules of the
|
||||||
|
/// [UTF-8](https://www.rfc-editor.org/rfc/rfc3629) encoding).
|
||||||
|
UnsupportedEncoding,
|
||||||
|
/// The receiving entity has advertised a mandatory-to-negotiate stream
|
||||||
|
/// feature that the initiating entity does not support, and has offered
|
||||||
|
/// no other mandatory-to-negotiate feature alongside the unsupported feature.
|
||||||
|
UnsupportedFeature,
|
||||||
|
/// The initiating entity has sent a first-level child of the stream that
|
||||||
|
/// is not supported by the server, either because the receiving entity
|
||||||
|
/// does not understand the namespace or because the receiving entity
|
||||||
|
/// does not understand the element name for the applicable namespace
|
||||||
|
/// (which might be the content namespace declared as the default namespace).
|
||||||
|
UnsupportedStanzaType,
|
||||||
|
/// The 'version' attribute provided by the initiating entity in the
|
||||||
|
/// stream header specifies a version of XMPP that is not supported by
|
||||||
|
/// the server.
|
||||||
|
UnsupportedVersion,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<FromUtf8Error> for StreamError {
|
||||||
|
fn from(_: FromUtf8Error) -> Self {
|
||||||
|
Self::UnsupportedEncoding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AttrError> for StreamError {
|
||||||
|
fn from(_: AttrError) -> Self {
|
||||||
|
Self::NotWellFormed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<quick_xml::Error> for StreamError {
|
||||||
|
fn from(value: quick_xml::Error) -> Self {
|
||||||
|
match value {
|
||||||
|
quick_xml::Error::Io(err) => {
|
||||||
|
error!("io error: {err}");
|
||||||
|
Self::InternalServerError
|
||||||
|
}
|
||||||
|
quick_xml::Error::NonDecodable(_) => Self::UnsupportedEncoding,
|
||||||
|
_ => Self::BadFormat,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for StreamError {}
|
|
@ -0,0 +1,55 @@
|
||||||
|
use std::vec;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use quick_xml::{
|
||||||
|
events::{BytesEnd, BytesStart, Event},
|
||||||
|
Writer,
|
||||||
|
};
|
||||||
|
use tokio::io::AsyncWrite;
|
||||||
|
|
||||||
|
use crate::{error::StreamError, tag};
|
||||||
|
|
||||||
|
pub struct Feature {
|
||||||
|
name: &'static str,
|
||||||
|
required: bool,
|
||||||
|
namespace: Option<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl tag::Tag for Feature {
|
||||||
|
async fn write_tag<W>(&self, writer: W) -> Result<(), StreamError>
|
||||||
|
where
|
||||||
|
W: AsyncWrite + Unpin + Send,
|
||||||
|
{
|
||||||
|
let mut writer = Writer::new(writer);
|
||||||
|
writer
|
||||||
|
.write_event_async(Event::Start(BytesStart::new(self.name).with_attributes(
|
||||||
|
if let Some(namespace) = self.namespace {
|
||||||
|
vec![("xmlns", namespace)]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
},
|
||||||
|
)))
|
||||||
|
.await?;
|
||||||
|
if self.required {
|
||||||
|
writer
|
||||||
|
.write_event_async(Event::Empty(BytesStart::new("required")))
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
writer
|
||||||
|
.write_event_async(Event::End(BytesEnd::new(self.name)))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Feature {
|
||||||
|
pub const fn start_tls(required: bool) -> Feature {
|
||||||
|
Feature {
|
||||||
|
required,
|
||||||
|
name: "starttls",
|
||||||
|
namespace: Some(tag::TLS_NAMESPACE),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,42 @@
|
||||||
|
use std::process;
|
||||||
|
|
||||||
|
use log::{error, info};
|
||||||
|
|
||||||
|
mod config;
|
||||||
|
mod error;
|
||||||
|
mod feature;
|
||||||
|
mod negotiator;
|
||||||
|
mod server;
|
||||||
|
mod streamstart;
|
||||||
|
mod tag;
|
||||||
|
mod tls;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<(), anyhow::Error> {
|
||||||
|
if !std::env::var("LOG").is_ok() {
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
std::env::set_var("LOG", "debug");
|
||||||
|
#[cfg(not(debug_assertions))]
|
||||||
|
std::env::set_var("LOG", "info");
|
||||||
|
}
|
||||||
|
pretty_env_logger::init_custom_env("LOG");
|
||||||
|
|
||||||
|
let cfg = match config::Config::load() {
|
||||||
|
Ok(cfg) => cfg,
|
||||||
|
Err(err) => {
|
||||||
|
error!(
|
||||||
|
"getting config: {err}. writing default to {}",
|
||||||
|
config::DEFAULT_PATH
|
||||||
|
);
|
||||||
|
config::Config::default().save(config::DEFAULT_PATH)?;
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
info!("checking for certificates");
|
||||||
|
let certs = cfg.certificate().await.expect("getting certificates");
|
||||||
|
|
||||||
|
let host = cfg.hostname();
|
||||||
|
info!("listening on {host}:{}!", cfg.port);
|
||||||
|
server::listen(host, cfg.port).await.unwrap();
|
||||||
|
Ok(())
|
||||||
|
}
|
|
@ -0,0 +1,79 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use quick_xml::{
|
||||||
|
events::{BytesStart, Event},
|
||||||
|
Writer,
|
||||||
|
};
|
||||||
|
use tokio::io::{AsyncBufRead, AsyncWrite};
|
||||||
|
use tokio_rustls::rustls;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::StreamError,
|
||||||
|
tag::{self, Tag},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub enum Step {
|
||||||
|
Proceed,
|
||||||
|
Failure,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tag for Step {
|
||||||
|
async fn write_tag<W>(&self, writer: W) -> Result<(), crate::error::StreamError>
|
||||||
|
where
|
||||||
|
W: AsyncWrite + Unpin + Send,
|
||||||
|
{
|
||||||
|
let mut writer = Writer::new(writer);
|
||||||
|
writer
|
||||||
|
.write_event_async(Event::Empty(
|
||||||
|
BytesStart::new(match self {
|
||||||
|
Step::Proceed => "proceed",
|
||||||
|
Step::Failure => "failure",
|
||||||
|
})
|
||||||
|
.with_attributes(vec![("xmlns", tag::TLS_NAMESPACE)]),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn start_tls<R, W>(
|
||||||
|
reader: R,
|
||||||
|
writer: W,
|
||||||
|
start_tls_event: BytesStart<'_>,
|
||||||
|
) -> Result<Step, StreamError>
|
||||||
|
where
|
||||||
|
R: AsyncBufRead + Unpin,
|
||||||
|
W: AsyncWrite + Unpin + Send,
|
||||||
|
{
|
||||||
|
match start_tls_event.try_get_attribute("xmlns") {
|
||||||
|
Ok(namespace) => {
|
||||||
|
if &namespace
|
||||||
|
.map(|a| String::from_utf8(a.value.as_ref().to_vec()).unwrap_or_default())
|
||||||
|
.unwrap_or_default()
|
||||||
|
!= tag::TLS_NAMESPACE
|
||||||
|
{
|
||||||
|
return Ok(Step::Failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => return Ok(Step::Failure),
|
||||||
|
}
|
||||||
|
|
||||||
|
// let config = rustls::ServerConfig::builder()
|
||||||
|
// .with_safe_defaults()
|
||||||
|
// .with_no_client_auth()
|
||||||
|
// .with_single_cert(certs, keys.remove(0))
|
||||||
|
// .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?;
|
||||||
|
|
||||||
|
Step::Proceed.write_tag(writer).await?;
|
||||||
|
|
||||||
|
// match TlsConnector::builder(). {
|
||||||
|
// Ok(conn) => conn.,
|
||||||
|
// Err(err) => {
|
||||||
|
// error!("getting a tls connector: {err}");
|
||||||
|
// return Ok(Step::Failure);
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(3));
|
||||||
|
todo!()
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
use log::info;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
use crate::streamstart;
|
||||||
|
|
||||||
|
pub async fn listen(hostname: String, port: u16) -> Result<(), anyhow::Error> {
|
||||||
|
let listener = TcpListener::bind(("0.0.0.0", port)).await?;
|
||||||
|
loop {
|
||||||
|
match listener.accept().await {
|
||||||
|
Ok(conn) => {
|
||||||
|
info!("opening connection from {}", conn.1);
|
||||||
|
streamstart::spawn(hostname.clone(), conn);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("listening: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,234 @@
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use log::{error, info};
|
||||||
|
use quick_xml::{
|
||||||
|
events::{attributes::Attributes, BytesDecl, BytesEnd, BytesStart, Event},
|
||||||
|
Reader, Writer,
|
||||||
|
};
|
||||||
|
use tokio::{
|
||||||
|
io::{AsyncWrite, AsyncWriteExt, BufReader},
|
||||||
|
net::{
|
||||||
|
tcp::{ReadHalf, WriteHalf},
|
||||||
|
TcpStream,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
error::StreamError,
|
||||||
|
feature::Feature,
|
||||||
|
negotiator::{self, Step},
|
||||||
|
tag::{self, Tag},
|
||||||
|
};
|
||||||
|
|
||||||
|
type Result<T> = std::result::Result<T, StreamError>;
|
||||||
|
|
||||||
|
const FEATURES: &'static [Feature] = &[Feature::start_tls(true)];
|
||||||
|
|
||||||
|
struct StreamStart<'a> {
|
||||||
|
reader: Reader<BufReader<ReadHalf<'a>>>,
|
||||||
|
writer: Writer<WriteHalf<'a>>,
|
||||||
|
buffer: Vec<u8>,
|
||||||
|
hostname: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> StreamStart<'a> {
|
||||||
|
fn new(stream: &'a mut TcpStream, hostname: String) -> Self {
|
||||||
|
let (read, write) = stream.split();
|
||||||
|
let (reader, writer) = (
|
||||||
|
Reader::from_reader(BufReader::new(read)),
|
||||||
|
Writer::new(write),
|
||||||
|
);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
reader,
|
||||||
|
writer,
|
||||||
|
hostname,
|
||||||
|
buffer: vec![],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_stream(mut self) {
|
||||||
|
match self.negotiate_stream().await {
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(err) => {
|
||||||
|
if let Err(err2) = error(self.writer.get_mut(), err).await {
|
||||||
|
error!("error writing error: {err2}");
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
info!("wrote error {err}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = self.writer.get_mut().write_all(b"</stream:stream>").await {
|
||||||
|
error!("writing end to stream: {e}")
|
||||||
|
}
|
||||||
|
if let Err(e) = self.writer.get_mut().shutdown().await {
|
||||||
|
error!("shutting down stream: {e}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn negotiate_stream(&mut self) -> Result<()> {
|
||||||
|
let attrs = loop {
|
||||||
|
match self.reader.read_event_into_async(&mut self.buffer).await? {
|
||||||
|
Event::Start(start) => {
|
||||||
|
if start.name().as_ref() == tag::STREAM_ELEMENT_NAME {
|
||||||
|
let attrs: StreamAttrs = start.attributes().try_into()?;
|
||||||
|
if attrs.namespace != XMLNamespace::JabberClient {
|
||||||
|
return Err(StreamError::InvalidNamespace);
|
||||||
|
}
|
||||||
|
break attrs;
|
||||||
|
} else {
|
||||||
|
info!("element: {:?}", start);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::End(_) => return Err(StreamError::BadFormat),
|
||||||
|
Event::Eof => return Err(StreamError::BadFormat),
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
info!("starting negotiation with: {attrs:?}");
|
||||||
|
self.write_stream_header(StreamAttrs {
|
||||||
|
from: attrs.to.clone(),
|
||||||
|
to: attrs.from,
|
||||||
|
namespace: XMLNamespace::JabberClient,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
if attrs.to != self.hostname {
|
||||||
|
return Err(StreamError::HostUnknown);
|
||||||
|
}
|
||||||
|
self.send_features().await?;
|
||||||
|
loop {
|
||||||
|
match self.reader.read_event_into_async(&mut self.buffer).await? {
|
||||||
|
Event::Empty(empty) => match empty.name().as_ref() {
|
||||||
|
tag::STARTTLS => {
|
||||||
|
info!("starttls negotiation");
|
||||||
|
if let Step::Failure = negotiator::start_tls(
|
||||||
|
self.reader.get_mut(),
|
||||||
|
self.writer.get_mut(),
|
||||||
|
empty,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Step::Failure.write_tag(self.writer.get_mut()).await;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_ => return Err(StreamError::UnsupportedFeature),
|
||||||
|
},
|
||||||
|
Event::End(_) => return Err(StreamError::BadFormat),
|
||||||
|
Event::Eof => return Err(StreamError::BadFormat),
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(StreamError::InternalServerError)
|
||||||
|
}
|
||||||
|
async fn write_stream_header(&mut self, req: StreamAttrs) -> Result<()> {
|
||||||
|
self.writer
|
||||||
|
.write_event_async(Event::Decl(BytesDecl::new("1.0", Some("utf-8"), None)))
|
||||||
|
.await?;
|
||||||
|
self.writer
|
||||||
|
.write_event_async(Event::Start(
|
||||||
|
BytesStart::new("stream:stream").with_attributes(vec![
|
||||||
|
("from", req.from.as_str()),
|
||||||
|
("to", req.to.as_str()),
|
||||||
|
("xmlns:stream", "http://etherx.jabber.org/streams"),
|
||||||
|
("xml:lang", "en"),
|
||||||
|
("version", "1.0"),
|
||||||
|
]),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send_features(&mut self) -> Result<()> {
|
||||||
|
self.writer
|
||||||
|
.write_event_async(Event::Start(BytesStart::new(tag::FEATURE)))
|
||||||
|
.await?;
|
||||||
|
for feature in FEATURES.into_iter() {
|
||||||
|
feature.write_tag(self.writer.get_mut()).await?;
|
||||||
|
}
|
||||||
|
self.writer
|
||||||
|
.write_event_async(Event::End(BytesEnd::new(tag::FEATURE)))
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn spawn(hostname: String, (mut stream, _): (TcpStream, SocketAddr)) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
StreamStart::new(&mut stream, hostname).start_stream().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn error<W: AsyncWrite + Unpin>(writer: W, err: StreamError) -> Result<()> {
|
||||||
|
let mut writer = Writer::new(writer);
|
||||||
|
let err = err.to_string();
|
||||||
|
writer
|
||||||
|
.write_event_async(Event::Start(BytesStart::new(tag::ERROR_ELEMENT)))
|
||||||
|
.await?;
|
||||||
|
writer
|
||||||
|
.write_event_async(Event::Start(
|
||||||
|
BytesStart::new(&err)
|
||||||
|
.with_attributes(vec![("xmlns", "urn:ietf:params:xml:ns:xmpp-streams")]),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
writer
|
||||||
|
.write_event_async(Event::End(BytesEnd::new(&err)))
|
||||||
|
.await?;
|
||||||
|
writer
|
||||||
|
.write_event_async(Event::End(BytesEnd::new(tag::ERROR_ELEMENT)))
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct StreamAttrs {
|
||||||
|
from: String,
|
||||||
|
to: String,
|
||||||
|
namespace: XMLNamespace,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<Attributes<'_>> for StreamAttrs {
|
||||||
|
type Error = StreamError;
|
||||||
|
|
||||||
|
fn try_from(value: Attributes<'_>) -> std::result::Result<Self, Self::Error> {
|
||||||
|
let mut from: Option<String> = None;
|
||||||
|
let mut to: Option<String> = None;
|
||||||
|
let mut ns: Option<XMLNamespace> = None;
|
||||||
|
for v in value {
|
||||||
|
let v = v?;
|
||||||
|
match v.key.local_name().into_inner() {
|
||||||
|
b"from" => {
|
||||||
|
from = Some(String::from_utf8(v.value.to_vec())?);
|
||||||
|
}
|
||||||
|
b"to" => {
|
||||||
|
to = Some(String::from_utf8(v.value.to_vec())?);
|
||||||
|
}
|
||||||
|
b"xmlns" => match v.value.to_vec().as_slice() {
|
||||||
|
b"jabber:client" => {
|
||||||
|
ns = Some(XMLNamespace::JabberClient);
|
||||||
|
}
|
||||||
|
_ => return Err(StreamError::InvalidNamespace),
|
||||||
|
},
|
||||||
|
other => {
|
||||||
|
info!(
|
||||||
|
"ignoring key {}",
|
||||||
|
String::from_utf8(other.to_vec()).unwrap_or_default()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(StreamAttrs {
|
||||||
|
from: from.ok_or(StreamError::InvalidFrom)?,
|
||||||
|
to: to.ok_or(StreamError::HostUnknown)?,
|
||||||
|
namespace: ns.ok_or(StreamError::BadNamespacePrefix)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum XMLNamespace {
|
||||||
|
JabberClient,
|
||||||
|
}
|
|
@ -0,0 +1,52 @@
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use quick_xml::{
|
||||||
|
events::{BytesEnd, BytesStart, Event},
|
||||||
|
Writer,
|
||||||
|
};
|
||||||
|
use tokio::io::AsyncWrite;
|
||||||
|
|
||||||
|
use crate::error::StreamError;
|
||||||
|
|
||||||
|
pub const STREAM_ELEMENT_NAME: &[u8] = b"stream:stream";
|
||||||
|
pub const ERROR_ELEMENT: &str = "stream:error";
|
||||||
|
pub const TLS_NAMESPACE: &str = "urn:ietf:params:xml:ns:xmpp-tls";
|
||||||
|
pub const STARTTLS: &[u8] = b"starttls";
|
||||||
|
pub const FEATURE: &str = "stream:features";
|
||||||
|
|
||||||
|
pub struct HollowTag<'a> {
|
||||||
|
name: &'a str,
|
||||||
|
namespace: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> From<(&'a str, &'static str)> for HollowTag<'a> {
|
||||||
|
fn from((name, namespace): (&'a str, &'static str)) -> Self {
|
||||||
|
Self { name, namespace }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Tag {
|
||||||
|
async fn write_tag<W>(&self, writer: W) -> Result<(), StreamError>
|
||||||
|
where
|
||||||
|
W: AsyncWrite + Unpin + Send;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl<'a> Tag for HollowTag<'a> {
|
||||||
|
async fn write_tag<W>(&self, writer: W) -> Result<(), StreamError>
|
||||||
|
where
|
||||||
|
W: AsyncWrite + Unpin + Send,
|
||||||
|
{
|
||||||
|
let mut writer = Writer::new(writer);
|
||||||
|
writer
|
||||||
|
.write_event_async(Event::Start(
|
||||||
|
BytesStart::new(self.name).with_attributes(vec![("xmlns", self.namespace)]),
|
||||||
|
))
|
||||||
|
.await?;
|
||||||
|
writer
|
||||||
|
.write_event_async(Event::End(BytesEnd::new(self.name)))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
|
Loading…
Reference in New Issue