wip initial state w/ not working certs

This commit is contained in:
emilis 2023-06-30 20:09:59 +01:00
commit 5b9dcd6a60
18 changed files with 4367 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/target
salut.toml
.vscode
input.txt

1722
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

9
Cargo.toml Normal file
View File

@ -0,0 +1,9 @@
[workspace]
members = ["salut", "desec"]
[profile.release]
lto = "fat"
opt-level = 3
strip = "symbols"
panic = "abort"

20
desec/Cargo.toml Normal file
View File

@ -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"

71
desec/src/dns.rs Normal file
View File

@ -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>,
}

19
desec/src/domains.rs Normal file
View File

@ -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,
}

205
desec/src/lib.rs Normal file
View File

@ -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
)
}

1359
salut/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

22
salut/Cargo.toml Normal file
View File

@ -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"

298
salut/src/config.rs Normal file
View File

@ -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)
}
}

155
salut/src/error.rs Normal file
View File

@ -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 {}

55
salut/src/feature.rs Normal file
View File

@ -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),
}
}
}

42
salut/src/main.rs Normal file
View File

@ -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(())
}

79
salut/src/negotiator.rs Normal file
View File

@ -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!()
}

20
salut/src/server.rs Normal file
View File

@ -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;
}
}
}
}

234
salut/src/streamstart.rs Normal file
View File

@ -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,
}

52
salut/src/tag.rs Normal file
View File

@ -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(())
}
}

1
salut/src/tls/mod.rs Normal file
View File

@ -0,0 +1 @@