From 1f3aa12401f25cd5ba3378f21a097686d7fb9bde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Berthaud-M=C3=BCller?= Date: Sun, 2 May 2021 17:19:32 +0200 Subject: [PATCH 01/19] basic zone configuration --- dev-scripts/config/knot.conf | 24 +++++++++++++++ dev-scripts/docker-compose.yml | 8 +++++ dev-scripts/zones/example.com.zone | 13 ++++++++ src/main.rs | 1 + src/models/users.rs | 26 +++++++++++++++- src/routes/zones.rs | 49 ++++++++++++++++++++++++------ 6 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 dev-scripts/config/knot.conf create mode 100644 dev-scripts/docker-compose.yml create mode 100644 dev-scripts/zones/example.com.zone diff --git a/dev-scripts/config/knot.conf b/dev-scripts/config/knot.conf new file mode 100644 index 0000000..34b3dba --- /dev/null +++ b/dev-scripts/config/knot.conf @@ -0,0 +1,24 @@ +server: + listen: [ 0.0.0.0@5353, ::@5353 ] + +log: + - target: stderr + any: debug + +acl: + - id: example_acl + address: [ 127.0.0.1, ::1] + action: transfer + +template: + - id: default + file: "zones/%s.zone" + journal-content: all + zonefile-load: difference-no-serial + zonefile-sync: -1 + serial-policy: dateserial + +zone: + - domain: example.com + acl: example_acl + template: default diff --git a/dev-scripts/docker-compose.yml b/dev-scripts/docker-compose.yml new file mode 100644 index 0000000..c509d4b --- /dev/null +++ b/dev-scripts/docker-compose.yml @@ -0,0 +1,8 @@ +services: + knot: + image: cznic/knot + volumes: + - $PWD/zones:/storage/zones:ro + - $PWD/config:/config:ro + command: knotd + network_mode: host diff --git a/dev-scripts/zones/example.com.zone b/dev-scripts/zones/example.com.zone new file mode 100644 index 0000000..aea8c99 --- /dev/null +++ b/dev-scripts/zones/example.com.zone @@ -0,0 +1,13 @@ +example.com. IN SOA ns.example.com. admin.example.com. ( + 2020250101 ; serial + 28800 ; refresh (8 hours) + 7200 ; retry (2 hours) + 2419200 ; expire (4 weeks) + 300 ; minimum (5 minutes) + ) + +example.com. 84600 IN NS ns.example.com. + +srv1.example.com. 600 IN A 198.51.100.3 +srv1.example.com. 600 IN AAAA 2001:db8:cafe:bc68::2 +www 600 IN CNAME srv1 diff --git a/src/main.rs b/src/main.rs index 7dff5a5..676e88e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,6 +27,7 @@ async fn rocket() -> rocket::Rocket { .mount("/api/v1", routes![ get_zone_records, get_zones, + create_zone, add_member_to_zone, create_auth_token, create_user diff --git a/src/models/users.rs b/src/models/users.rs index 2c9d334..eb713b4 100644 --- a/src/models/users.rs +++ b/src/models/users.rs @@ -21,7 +21,7 @@ use crate::schema::*; use crate::DbConn; use crate::config::Config; use crate::models::errors::{ErrorResponse, make_500}; - +use crate::models::dns::AbsoluteName; const BEARER: &str = "Bearer "; const AUTH_HEADER: &str = "Authentication"; @@ -82,6 +82,11 @@ pub struct AddZoneMemberRequest { pub id: String, } +#[derive(Debug, Deserialize)] +pub struct CreateZoneRequest { + pub name: AbsoluteName, +} + // pub struct LdapUserAssociation { // user_id: Uuid, // ldap_id: String @@ -366,6 +371,25 @@ impl Zone { }) } + pub fn create_zone(conn: &diesel::SqliteConnection, zone_request: CreateZoneRequest) -> Result { + use crate::schema::zone::dsl::*; + + let new_zone = Zone { + id: Uuid::new_v4().to_simple().to_string(), + name: zone_request.name.to_utf8(), + }; + + diesel::insert_into(zone) + .values(&new_zone) + .execute(conn) + .map_err(|e| match e { + DieselError::DatabaseError(diesel::result::DatabaseErrorKind::UniqueViolation, _) => UserError::UserConflict, + other => UserError::DbError(other) + })?; + Ok(new_zone) + } + + pub fn add_member(&self, conn: &diesel::SqliteConnection, new_member: &UserInfo) -> Result<(), UserError> { use crate::schema::user_zone::dsl::*; diff --git a/src/routes/zones.rs b/src/routes/zones.rs index c893b5d..c29f2b1 100644 --- a/src/routes/zones.rs +++ b/src/routes/zones.rs @@ -9,7 +9,7 @@ use trust_dns_client::rr::{DNSClass, RecordType}; use crate::{DbConn, models::dns}; use crate::models::errors::{ErrorResponse, make_500}; -use crate::models::users::{LocalUser, UserInfo, Zone, AddZoneMemberRequest}; +use crate::models::users::{LocalUser, UserInfo, Zone, AddZoneMemberRequest, CreateZoneRequest}; #[get("/zones//records")] @@ -21,22 +21,23 @@ pub async fn get_zone_records( ) -> Result>, ErrorResponse> { let user_info = user_info?; + let zone_name = zone.to_string(); - if !user_info.is_admin() { - let zone_name = zone.clone().to_string(); - conn.run(move |c| { + conn.run(move |c| { + if user_info.is_admin() { + Zone::get_by_name(c, &zone_name) + } else { user_info.get_zone(c, &zone_name) - }).await?; - } + } + }).await?; let response = { let query = client.query(zone.clone(), DNSClass::IN, RecordType::AXFR); query.await.map_err(make_500)? }; - // TODO: Better error handling (ex. not authorized should be 500) if response.response_code() != ResponseCode::NoError { - println!("Querrying of zone {} failed with code {}", *zone, response.response_code()); + println!("Querrying AXFR of zone {} failed with code {}", *zone, response.response_code()); return ErrorResponse::new( Status::NotFound, format!("Zone {} could not be found", *zone) @@ -55,7 +56,6 @@ pub async fn get_zone_records( Ok(Json(records)) } -// TODO: the post version of that #[get("/zones")] pub async fn get_zones( conn: DbConn, @@ -74,6 +74,37 @@ pub async fn get_zones( Ok(Json(zones)) } +#[post("/zones", data = "")] +pub async fn create_zone( + conn: DbConn, + mut client: dns::DnsClient, + user_info: Result, + zone_request: Json, +) -> Result, ErrorResponse> { + user_info?.check_admin()?; + + + // Check if the zone exists in the DNS server + let response = { + let query = client.query(zone_request.name.clone(), DNSClass::IN, RecordType::SOA); + query.await.map_err(make_500)? + }; + + if response.response_code() != ResponseCode::NoError { + println!("Querrying SOA of zone {} failed with code {}", *zone_request.name, response.response_code()); + return ErrorResponse::new( + Status::NotFound, + format!("Zone {} could not be found", *zone_request.name) + ).err() + } + + let zone = conn.run(move |c| { + Zone::create_zone(c, zone_request.into_inner()) + }).await?; + + Ok(Json(zone)) +} + #[post("/zones//members", data = "")] pub async fn add_member_to_zone<'r>( From b9ca63bed108d99c42eab4a139ab323fc4eccbb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Berthaud-M=C3=BCller?= Date: Thu, 1 Jul 2021 20:45:49 +0200 Subject: [PATCH 02/19] wip create record --- dev-scripts/config/knot.conf | 2 +- dev-scripts/docker-compose.yml | 4 +- src/main.rs | 1 + src/models/dns.rs | 191 ++++++++++++++++++++++++++------- src/routes/zones.rs | 90 +++++++++++++++- 5 files changed, 242 insertions(+), 46 deletions(-) diff --git a/dev-scripts/config/knot.conf b/dev-scripts/config/knot.conf index 34b3dba..e6e04ef 100644 --- a/dev-scripts/config/knot.conf +++ b/dev-scripts/config/knot.conf @@ -8,7 +8,7 @@ log: acl: - id: example_acl address: [ 127.0.0.1, ::1] - action: transfer + action: [transfer, update] template: - id: default diff --git a/dev-scripts/docker-compose.yml b/dev-scripts/docker-compose.yml index c509d4b..5738e1c 100644 --- a/dev-scripts/docker-compose.yml +++ b/dev-scripts/docker-compose.yml @@ -2,7 +2,7 @@ services: knot: image: cznic/knot volumes: - - $PWD/zones:/storage/zones:ro - - $PWD/config:/config:ro + - ./zones:/storage/zones:ro + - ./config:/config:ro command: knotd network_mode: host diff --git a/src/main.rs b/src/main.rs index 676e88e..e4ed571 100644 --- a/src/main.rs +++ b/src/main.rs @@ -26,6 +26,7 @@ async fn rocket() -> rocket::Rocket { .attach(DbConn::fairing()) .mount("/api/v1", routes![ get_zone_records, + create_zone_records, get_zones, create_zone, add_member_to_zone, diff --git a/src/models/dns.rs b/src/models/dns.rs index 2bb22b9..7f3e8a3 100644 --- a/src/models/dns.rs +++ b/src/models/dns.rs @@ -1,15 +1,15 @@ -use std::net::{Ipv6Addr, Ipv4Addr}; +use std::{convert::{TryFrom, TryInto}, future::Future, net::{Ipv6Addr, Ipv4Addr}, pin::Pin, task::{Context, Poll}}; use std::fmt; use std::ops::{Deref, DerefMut}; use rocket::{Request, State, http::Status, request::{FromParam, FromRequest, Outcome}}; -use serde::{Deserialize, Deserializer, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use tokio::{net::TcpStream as TokioTcpStream, task}; -use trust_dns_client::{client::AsyncClient, serialize::binary::BinEncoder, tcp::TcpClientStream}; +use trust_dns_client::{client::AsyncClient, error::ClientError, op::DnsResponse, serialize::binary::BinEncoder, tcp::TcpClientStream}; use trust_dns_proto::error::{ProtoError}; use trust_dns_proto::iocompat::AsyncIoTokioAsStd; @@ -38,14 +38,14 @@ pub enum RData { }, #[serde(rename_all = "PascalCase")] CNAME { - target: String + target: SerdeName }, // HINFO(HINFO), // HTTPS(SVCB), #[serde(rename_all = "PascalCase")] MX { preference: u16, - mail_exchanger: String + mail_exchanger: SerdeName }, // NAPTR(NAPTR), #[serde(rename_all = "PascalCase")] @@ -54,18 +54,18 @@ pub enum RData { }, #[serde(rename_all = "PascalCase")] NS { - target: String + target: SerdeName }, // OPENPGPKEY(OPENPGPKEY), // OPT(OPT), #[serde(rename_all = "PascalCase")] PTR { - target: String + target: SerdeName }, #[serde(rename_all = "PascalCase")] SOA { - master_server_name: String, - maintainer_name: String, + master_server_name: SerdeName, + maintainer_name: SerdeName, refresh: i32, retry: i32, expire: i32, @@ -74,7 +74,7 @@ pub enum RData { }, #[serde(rename_all = "PascalCase")] SRV { - server: String, + server: SerdeName, port: u16, priority: u16, weight: u16, @@ -115,7 +115,7 @@ impl From for RData { data: String::new() }, trust_dns_types::RData::CNAME(target) => RData::CNAME { - target: target.to_utf8() + target: SerdeName(target) }, trust_dns_types::RData::CAA(caa) => RData::CAA { issuer_critical: caa.issuer_critical(), @@ -124,20 +124,20 @@ impl From for RData { }, trust_dns_types::RData::MX(mx) => RData::MX { preference: mx.preference(), - mail_exchanger: mx.exchange().to_utf8() + mail_exchanger: SerdeName(mx.exchange().clone()) }, trust_dns_types::RData::NULL(null) => RData::NULL { data: base64::encode(null.anything().map(|data| data.to_vec()).unwrap_or_default()) }, trust_dns_types::RData::NS(target) => RData::NS { - target: target.to_utf8() + target: SerdeName(target) }, trust_dns_types::RData::PTR(target) => RData::PTR { - target: target.to_utf8() + target: SerdeName(target) }, trust_dns_types::RData::SOA(soa) => RData::SOA { - master_server_name: soa.mname().to_utf8(), - maintainer_name: soa.rname().to_utf8(), + master_server_name: SerdeName(soa.mname().clone()), + maintainer_name: SerdeName(soa.rname().clone()), refresh: soa.refresh(), retry: soa.retry(), expire: soa.expire(), @@ -145,7 +145,7 @@ impl From for RData { serial: soa.serial() }, trust_dns_types::RData::SRV(srv) => RData::SRV { - server: srv.target().to_utf8(), + server: SerdeName(srv.target().clone()), port: srv.port(), priority: srv.priority(), weight: srv.weight(), @@ -173,6 +173,51 @@ impl From for RData { } } +impl TryFrom for trust_dns_types::RData { + type Error = ProtoError; + + fn try_from(rdata: RData) -> Result { + Ok(match rdata { + RData::A { address } => trust_dns_types::RData::A(address), + RData::AAAA { address } => trust_dns_types::RData::AAAA(address), + RData::CAA { issuer_critical, value, property_tag } => { + let property = trust_dns_types::caa::Property::from(property_tag); + let caa_value = { + // TODO: duplicate of trust_dns_client::serialize::txt::rdata_parser::caa::parse + // because caa::read_value is private + match property { + trust_dns_types::caa::Property::Issue | trust_dns_types::caa::Property::IssueWild => { + let value = trust_dns_types::caa::read_issuer(value.as_bytes())?; + trust_dns_types::caa::Value::Issuer(value.0, value.1) + } + trust_dns_types::caa::Property::Iodef => { + let url = trust_dns_types::caa::read_iodef(value.as_bytes())?; + trust_dns_types::caa::Value::Url(url) + } + trust_dns_types::caa::Property::Unknown(_) => trust_dns_types::caa::Value::Unknown(value.as_bytes().to_vec()), + } + }; + trust_dns_types::RData::CAA(trust_dns_types::caa::CAA { + issuer_critical, + tag: property, + value: caa_value, + }) + }, + RData::CNAME { target } => todo!(), + RData::MX { preference, mail_exchanger } => todo!(), + RData::NULL { data } => todo!(), + RData::NS { target } => todo!(), + RData::PTR { target } => todo!(), + RData::SOA { master_server_name, maintainer_name, refresh, retry, expire, minimum, serial } => todo!(), + RData::SRV { server, port, priority, weight } => todo!(), + RData::SSHFP { algorithm, digest_type, fingerprint } => todo!(), + RData::TXT { text } => todo!(), + RData::DNSSEC(_) => todo!(), + RData::Unknown { code, data } => todo!(), + }) + } +} + struct CAAValue<'a>(&'a trust_dns_types::caa::Value); // trust_dns Display implementation panics if no parameters @@ -225,11 +270,44 @@ impl From for DNSClass { } } +impl From for trust_dns_types::DNSClass { + fn from(dns_class: DNSClass) -> trust_dns_types::DNSClass { + match dns_class { + DNSClass::IN => trust_dns_types::DNSClass::IN, + DNSClass::CH => trust_dns_types::DNSClass::CH, + DNSClass::HS => trust_dns_types::DNSClass::HS, + DNSClass::NONE => trust_dns_types::DNSClass::NONE, + DNSClass::ANY => trust_dns_types::DNSClass::ANY, + DNSClass::OPT(v) => trust_dns_types::DNSClass::OPT(v), + } + } +} + + +// Reimplement this type here as ClientReponse in trust-dns crate have private fields +pub struct ClientResponse(pub(crate) R) +where + R: Future> + Send + Unpin + 'static; + +impl Future for ClientResponse +where + R: Future> + Send + Unpin + 'static, +{ + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + // This is from the future_utils crate, we simply reuse the reexport from Rocket + rocket::futures::FutureExt::poll_unpin(&mut self.0, cx).map_err(ClientError::from) + } +} + + #[derive(Deserialize, Serialize)] pub struct Record { #[serde(rename = "Name")] - pub name: String, + pub name: SerdeName, + // TODO: Make class optional, default to IN #[serde(rename = "Class")] pub dns_class: DNSClass, #[serde(rename = "TTL")] @@ -241,8 +319,7 @@ pub struct Record { impl From for Record { fn from(record: trust_dns_types::Record) -> Record { Record { - name: record.name().to_utf8(), - //rr_type: record.rr_type().into(), + name: SerdeName(record.name().clone()), dns_class: record.dns_class().into(), ttl: record.ttl(), rdata: record.into_data().into(), @@ -250,8 +327,59 @@ impl From for Record { } } +impl TryFrom for trust_dns_types::Record { + type Error = ProtoError; + + fn try_from(record: Record) -> Result { + let mut trust_dns_record = trust_dns_types::Record::from_rdata(record.name.into_inner(), record.ttl, record.rdata.try_into()?); + trust_dns_record.set_dns_class(record.dns_class.into()); + Ok(trust_dns_record) + } +} + #[derive(Debug)] -pub struct AbsoluteName(Name); +pub struct SerdeName(Name); + +impl<'de> Deserialize<'de> for SerdeName { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de> + { + use serde::de::Error; + + String::deserialize(deserializer) + .and_then(|string| + Name::from_utf8(&string) + .map_err(|e| Error::custom(e.to_string())) + ).map( SerdeName) + } +} + +impl Deref for SerdeName { + type Target = Name; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl Serialize for SerdeName { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer + { + self.0.to_utf8().serialize(serializer) + } +} + +impl SerdeName { + fn into_inner(self) -> Name { + self.0 + } +} + + +#[derive(Debug, Deserialize)] +pub struct AbsoluteName(SerdeName); impl<'r> FromParam<'r> for AbsoluteName { type Error = ProtoError; @@ -261,33 +389,16 @@ impl<'r> FromParam<'r> for AbsoluteName { if !name.is_fqdn() { name.set_fqdn(true); } - Ok(AbsoluteName(name)) - } -} - -impl<'de> Deserialize<'de> for AbsoluteName { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de> - { - use serde::de::Error; - - String::deserialize(deserializer) - .and_then(|string| - AbsoluteName::from_param(&string) - .map_err(|e| Error::custom(e.to_string())) - ) + Ok(AbsoluteName(SerdeName(name))) } } impl Deref for AbsoluteName { type Target = Name; fn deref(&self) -> &Self::Target { - &self.0 + &self.0.0 } } - - pub struct DnsClient(AsyncClient); impl Deref for DnsClient { diff --git a/src/routes/zones.rs b/src/routes/zones.rs index c29f2b1..4da2978 100644 --- a/src/routes/zones.rs +++ b/src/routes/zones.rs @@ -1,13 +1,23 @@ +use std::convert::TryFrom; +use std::convert::TryInto; + use rocket::Response; use rocket::http::Status; use rocket_contrib::json::Json; -use trust_dns_client::client::ClientHandle; +use serde_json::json; + +use trust_dns_client::{client::ClientHandle, op::UpdateMessage}; use trust_dns_client::op::ResponseCode; use trust_dns_client::rr::{DNSClass, RecordType}; +use trust_dns_proto::DnsHandle; +pub use trust_dns_client::op::Message; +pub use trust_dns_client::op::OpCode; +pub use trust_dns_client::op::Query; +pub use trust_dns_client::op::MessageType; -use crate::{DbConn, models::dns}; +use crate::{DbConn, models::{dns, trust_dns_types}}; use crate::models::errors::{ErrorResponse, make_500}; use crate::models::users::{LocalUser, UserInfo, Zone, AddZoneMemberRequest, CreateZoneRequest}; @@ -56,6 +66,81 @@ pub async fn get_zone_records( Ok(Json(records)) } +#[post("/zones//records", data = "")] +pub async fn create_zone_records( + mut client: dns::DnsClient, + conn: DbConn, + user_info: Result, + zone: dns::AbsoluteName, + new_records: Json> +) -> Result, ErrorResponse> { + + let user_info = user_info?; + let zone_name = zone.to_utf8(); + + conn.run(move |c| { + if user_info.is_admin() { + Zone::get_by_name(c, &zone_name) + } else { + user_info.get_zone(c, &zone_name) + } + }).await?; + // TODO: What about relative names (also in cnames and stuff) + // TODO: error handling + let records: Vec = new_records.into_inner().into_iter().map(|r| r.try_into().unwrap()).collect(); + + let bad_zone_records: Vec<_> = records.iter().filter(|record| !zone.zone_of(record.name())).collect(); + // TODO: Get zone class from somewhere instead of always assuming IN + let bad_class_records: Vec<_> = records.iter().filter(|record| record.dns_class() != DNSClass::IN).collect(); + + if !bad_zone_records.is_empty() { + return ErrorResponse::new( + Status::BadRequest, + format!("Record list contains records whose name that do not belong to the zone {}", *zone) + ).with_details( + json!(bad_zone_records.into_iter().map(|r| r.name().to_utf8()).collect::>()) + ).err() + } + + if !bad_class_records.is_empty() { + return ErrorResponse::new( + Status::BadRequest, + "Record list contains records whose class differs from the zone class `IN`".into() + ).with_details( + json!(bad_class_records.into_iter().map(|r| r.name().to_utf8()).collect::>()) + ).err() + } + + let mut zone_query = Query::new(); + zone_query.set_name(zone.clone()) + .set_query_class(DNSClass::IN) + .set_query_type(RecordType::SOA); + let mut message = Message::new(); + + // TODO: set random / time based id + message + .set_id(0) + .set_message_type(MessageType::Query) + .set_op_code(OpCode::Update) + .set_recursion_desired(false); + message.add_zone(zone_query); + message.add_updates(records); + + { + let edns = message.edns_mut(); + edns.set_max_payload(1232); + edns.set_version(0); + } + + // TODO: check if NOERROR or something + let _response = { + let query = dns::ClientResponse(client.send(message)); + query.await.map_err(make_500)? + }; + + Ok(Json(())) +} + #[get("/zones")] pub async fn get_zones( conn: DbConn, @@ -83,7 +168,6 @@ pub async fn create_zone( ) -> Result, ErrorResponse> { user_info?.check_admin()?; - // Check if the zone exists in the DNS server let response = { let query = client.query(zone_request.name.clone(), DNSClass::IN, RecordType::SOA); From 936addb6240ff38acd2e3fca0f966783e02c4280 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Berthaud-M=C3=BCller?= Date: Sun, 11 Jul 2021 01:17:31 +0200 Subject: [PATCH 03/19] add deserialization support for more types --- src/models/dns.rs | 55 +++++++++++++++++++++++++++++++++++++++-------- src/models/mod.rs | 2 +- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/models/dns.rs b/src/models/dns.rs index 7f3e8a3..8c0f752 100644 --- a/src/models/dns.rs +++ b/src/models/dns.rs @@ -155,6 +155,7 @@ impl From for RData { digest_type: sshfp.fingerprint_type().into(), fingerprint: trust_dns_types::sshfp::HEX.encode(sshfp.fingerprint()), }, + //TODO: This might alter data if not utf8 compatible, probably need to be replaced trust_dns_types::RData::TXT(txt) => RData::TXT { text: format!("{}", txt) }, trust_dns_types::RData::DNSSEC(data) => RData::DNSSEC(data), rdata => { @@ -180,6 +181,7 @@ impl TryFrom for trust_dns_types::RData { Ok(match rdata { RData::A { address } => trust_dns_types::RData::A(address), RData::AAAA { address } => trust_dns_types::RData::AAAA(address), + // TODO: Round trip test all types below (currently not tested...) RData::CAA { issuer_critical, value, property_tag } => { let property = trust_dns_types::caa::Property::from(property_tag); let caa_value = { @@ -203,16 +205,51 @@ impl TryFrom for trust_dns_types::RData { value: caa_value, }) }, - RData::CNAME { target } => todo!(), - RData::MX { preference, mail_exchanger } => todo!(), - RData::NULL { data } => todo!(), - RData::NS { target } => todo!(), - RData::PTR { target } => todo!(), - RData::SOA { master_server_name, maintainer_name, refresh, retry, expire, minimum, serial } => todo!(), - RData::SRV { server, port, priority, weight } => todo!(), - RData::SSHFP { algorithm, digest_type, fingerprint } => todo!(), - RData::TXT { text } => todo!(), + RData::CNAME { target } => trust_dns_types::RData::CNAME(target.into_inner()), + RData::MX { preference, mail_exchanger } => trust_dns_types::RData::MX( + trust_dns_types::mx::MX::new(preference, mail_exchanger.into_inner()) + ), + RData::NULL { data } => trust_dns_types::RData::NULL( + trust_dns_types::null::NULL::with( + base64::decode(data).map_err(|e| ProtoError::from(format!("{}", e)))? + ) + ), + RData::NS { target } => trust_dns_types::RData::NS(target.into_inner()), + RData::PTR { target } => trust_dns_types::RData::PTR(target.into_inner()), + RData::SOA { + master_server_name, + maintainer_name, + refresh, + retry, + expire, + minimum, + serial + } => trust_dns_types::RData::SOA( + trust_dns_types::soa::SOA::new( + master_server_name.into_inner(), + maintainer_name.into_inner(), + serial, + refresh, + retry, + expire, + minimum, + ) + ), + RData::SRV { server, port, priority, weight } => trust_dns_types::RData::SRV( + trust_dns_types::srv::SRV::new(priority, weight, port, server.into_inner()) + ), + RData::SSHFP { algorithm, digest_type, fingerprint } => trust_dns_types::RData::SSHFP( + trust_dns_types::sshfp::SSHFP::new( + // NOTE: This allows unassigned algorithms + trust_dns_types::sshfp::Algorithm::from(algorithm), + trust_dns_types::sshfp::FingerprintType::from(digest_type), + trust_dns_types::sshfp::HEX.decode(fingerprint.as_bytes()).map_err(|e| ProtoError::from(format!("{}", e)))? + ) + ), + RData::TXT { text } => trust_dns_types::RData::TXT(trust_dns_types::txt::TXT::new(vec![text])), + // TODO: Error out for DNSSEC? Prefer downstream checks? RData::DNSSEC(_) => todo!(), + // TODO: Disallow unknown? (could be used to bypass unsopported types?) Prefer downstream checks? RData::Unknown { code, data } => todo!(), }) } diff --git a/src/models/mod.rs b/src/models/mod.rs index dd71b7c..a43b162 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -4,7 +4,7 @@ pub mod users; pub mod trust_dns_types { pub use trust_dns_client::rr::rdata::{ - DNSSECRData, caa, sshfp, + DNSSECRData, caa, sshfp, mx, null, soa, srv, txt }; pub use trust_dns_client::rr::{ RData, DNSClass, Record From b7db84e9a8c564a9259b659b66a8ebcaa25628f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Berthaud-M=C3=BCller?= Date: Fri, 4 Mar 2022 00:44:29 +0100 Subject: [PATCH 04/19] add basic record creation route --- src/models/dns.rs | 11 +++++--- src/models/users.rs | 2 +- src/routes/zones.rs | 67 +++++++++++++++++++++++++++++++++++---------- 3 files changed, 60 insertions(+), 20 deletions(-) diff --git a/src/models/dns.rs b/src/models/dns.rs index 8c0f752..c9d13b8 100644 --- a/src/models/dns.rs +++ b/src/models/dns.rs @@ -18,7 +18,7 @@ use super::trust_dns_types::{self, Name}; use crate::config::Config; -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, Clone)] #[serde(tag = "Type")] #[serde(rename_all = "UPPERCASE")] pub enum RData { @@ -101,6 +101,8 @@ pub enum RData { data: String, }, // ZERO, + + // TODO: DS } impl From for RData { @@ -156,6 +158,7 @@ impl From for RData { fingerprint: trust_dns_types::sshfp::HEX.encode(sshfp.fingerprint()), }, //TODO: This might alter data if not utf8 compatible, probably need to be replaced + //TODO: check whether concatenating txt data is harmful or not trust_dns_types::RData::TXT(txt) => RData::TXT { text: format!("{}", txt) }, trust_dns_types::RData::DNSSEC(data) => RData::DNSSEC(data), rdata => { @@ -284,7 +287,7 @@ impl<'a> fmt::Display for CAAValue<'a> { } } -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, Clone)] pub enum DNSClass { IN, CH, @@ -340,7 +343,7 @@ where -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Serialize, Clone)] pub struct Record { #[serde(rename = "Name")] pub name: SerdeName, @@ -374,7 +377,7 @@ impl TryFrom for trust_dns_types::Record { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct SerdeName(Name); impl<'de> Deserialize<'de> for SerdeName { diff --git a/src/models/users.rs b/src/models/users.rs index eb713b4..d19142c 100644 --- a/src/models/users.rs +++ b/src/models/users.rs @@ -24,7 +24,7 @@ use crate::models::errors::{ErrorResponse, make_500}; use crate::models::dns::AbsoluteName; const BEARER: &str = "Bearer "; -const AUTH_HEADER: &str = "Authentication"; +const AUTH_HEADER: &str = "Authorization"; #[derive(Debug, DbEnum, Deserialize, Clone)] diff --git a/src/routes/zones.rs b/src/routes/zones.rs index 4da2978..ae7ddeb 100644 --- a/src/routes/zones.rs +++ b/src/routes/zones.rs @@ -1,13 +1,10 @@ -use std::convert::TryFrom; use std::convert::TryInto; - +use serde_json::json; use rocket::Response; use rocket::http::Status; use rocket_contrib::json::Json; -use serde_json::json; - use trust_dns_client::{client::ClientHandle, op::UpdateMessage}; use trust_dns_client::op::ResponseCode; use trust_dns_client::rr::{DNSClass, RecordType}; @@ -50,8 +47,10 @@ pub async fn get_zone_records( println!("Querrying AXFR of zone {} failed with code {}", *zone, response.response_code()); return ErrorResponse::new( Status::NotFound, - format!("Zone {} could not be found", *zone) - ).err() + "Zone could not be found".into() + ).with_details(json!({ + "zone_name": zone.to_utf8() + })).err(); } let answers = response.answers(); @@ -85,21 +84,46 @@ pub async fn create_zone_records( user_info.get_zone(c, &zone_name) } }).await?; + // TODO: What about relative names (also in cnames and stuff) - // TODO: error handling - let records: Vec = new_records.into_inner().into_iter().map(|r| r.try_into().unwrap()).collect(); + let mut bad_records = Vec::new(); + let mut records: Vec = Vec::new(); + + for record in new_records.into_inner().into_iter() { + let this_record = record.clone(); + if let Ok(record) = record.try_into() { + records.push(record); + } else { + bad_records.push(this_record.clone()); + } + } let bad_zone_records: Vec<_> = records.iter().filter(|record| !zone.zone_of(record.name())).collect(); // TODO: Get zone class from somewhere instead of always assuming IN let bad_class_records: Vec<_> = records.iter().filter(|record| record.dns_class() != DNSClass::IN).collect(); + if !bad_records.is_empty() { + return ErrorResponse::new( + Status::BadRequest, + "Record list contains records that could not been parsed into DNS records".into() + ).with_details( + json!({ + "zone_name": zone.to_utf8(), + "records": bad_records + }) + ).err(); + } + if !bad_zone_records.is_empty() { return ErrorResponse::new( Status::BadRequest, - format!("Record list contains records whose name that do not belong to the zone {}", *zone) + "Record list contains records whose name does not belong to the zone".into() ).with_details( - json!(bad_zone_records.into_iter().map(|r| r.name().to_utf8()).collect::>()) - ).err() + json!({ + "zone_name": zone.to_utf8(), + "records": bad_zone_records.into_iter().map(|r| r.clone().into()).collect::>() + }) + ).err(); } if !bad_class_records.is_empty() { @@ -107,8 +131,11 @@ pub async fn create_zone_records( Status::BadRequest, "Record list contains records whose class differs from the zone class `IN`".into() ).with_details( - json!(bad_class_records.into_iter().map(|r| r.name().to_utf8()).collect::>()) - ).err() + json!({ + "zone_name": zone.to_utf8(), + "records": bad_class_records.into_iter().map(|r| r.clone().into()).collect::>() + }) + ).err(); } let mut zone_query = Query::new(); @@ -132,12 +159,22 @@ pub async fn create_zone_records( edns.set_version(0); } - // TODO: check if NOERROR or something - let _response = { + let response = { let query = dns::ClientResponse(client.send(message)); query.await.map_err(make_500)? }; + // TODO: better error handling + if response.response_code() != ResponseCode::NoError { + println!("Update of zone {} failed with code {}", *zone, response.response_code()); + return ErrorResponse::new( + Status::NotFound, + "Update of zone failed".into() + ).with_details(json!({ + "zone_name": zone.to_utf8() + })).err(); + } + Ok(Json(())) } From 4a4362715c9fd840b8b861f0e2c232c1f1d0626c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Berthaud-M=C3=BCller?= Date: Fri, 4 Mar 2022 13:08:03 +0100 Subject: [PATCH 05/19] refactor --- src/dns/class.rs | 40 ++++++ src/dns/client.rs | 71 +++++++++ src/dns/message.rs | 67 +++++++++ src/dns/mod.rs | 16 +++ src/dns/name.rs | 72 ++++++++++ src/{models/dns.rs => dns/rdata.rs} | 214 ++-------------------------- src/dns/record.rs | 43 ++++++ src/main.rs | 1 + src/models/auth.rs | 63 ++++++++ src/models/errors.rs | 30 +++- src/models/mod.rs | 16 +-- src/models/{users.rs => user.rs} | 180 +---------------------- src/models/zone.rs | 93 ++++++++++++ src/routes/users.rs | 9 +- src/routes/zones.rs | 98 +++++-------- src/schema.rs | 2 +- 16 files changed, 549 insertions(+), 466 deletions(-) create mode 100644 src/dns/class.rs create mode 100644 src/dns/client.rs create mode 100644 src/dns/message.rs create mode 100644 src/dns/mod.rs create mode 100644 src/dns/name.rs rename src/{models/dns.rs => dns/rdata.rs} (63%) create mode 100644 src/dns/record.rs create mode 100644 src/models/auth.rs rename src/models/{users.rs => user.rs} (61%) create mode 100644 src/models/zone.rs diff --git a/src/dns/class.rs b/src/dns/class.rs new file mode 100644 index 0000000..e95eab8 --- /dev/null +++ b/src/dns/class.rs @@ -0,0 +1,40 @@ +use serde::{Deserialize, Serialize}; + +use super::trust_dns_types; + + +#[derive(Deserialize, Serialize, Clone)] +pub enum DNSClass { + IN, + CH, + HS, + NONE, + ANY, + OPT(u16), +} + +impl From for DNSClass { + fn from(dns_class: trust_dns_types::DNSClass) -> DNSClass { + match dns_class { + trust_dns_types::DNSClass::IN => DNSClass::IN, + trust_dns_types::DNSClass::CH => DNSClass::CH, + trust_dns_types::DNSClass::HS => DNSClass::HS, + trust_dns_types::DNSClass::NONE => DNSClass::NONE, + trust_dns_types::DNSClass::ANY => DNSClass::ANY, + trust_dns_types::DNSClass::OPT(v) => DNSClass::OPT(v), + } + } +} + +impl From for trust_dns_types::DNSClass { + fn from(dns_class: DNSClass) -> trust_dns_types::DNSClass { + match dns_class { + DNSClass::IN => trust_dns_types::DNSClass::IN, + DNSClass::CH => trust_dns_types::DNSClass::CH, + DNSClass::HS => trust_dns_types::DNSClass::HS, + DNSClass::NONE => trust_dns_types::DNSClass::NONE, + DNSClass::ANY => trust_dns_types::DNSClass::ANY, + DNSClass::OPT(v) => trust_dns_types::DNSClass::OPT(v), + } + } +} \ No newline at end of file diff --git a/src/dns/client.rs b/src/dns/client.rs new file mode 100644 index 0000000..da942fe --- /dev/null +++ b/src/dns/client.rs @@ -0,0 +1,71 @@ +use std::{future::Future, pin::Pin, task::{Context, Poll}}; +use std::ops::{Deref, DerefMut}; + +use rocket::{Request, State, http::Status, request::{FromRequest, Outcome}}; +use tokio::{net::TcpStream as TokioTcpStream, task}; +use trust_dns_client::{client::AsyncClient, error::ClientError, op::DnsResponse, tcp::TcpClientStream}; +use trust_dns_proto::error::ProtoError; +use trust_dns_proto::iocompat::AsyncIoTokioAsStd; + +use crate::config::Config; +use super::message::DnsMessage; + + +pub struct DnsClient(AsyncClient); + +impl Deref for DnsClient { + type Target = AsyncClient; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for DnsClient { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl DnsMessage for AsyncClient {} + + +#[rocket::async_trait] +impl<'r> FromRequest<'r> for DnsClient { + type Error = (); + async fn from_request(request: &'r Request<'_>) -> Outcome { + let config = try_outcome!(request.guard::>().await); + let (stream, handle) = TcpClientStream::>::new(config.dns.server); + let client = AsyncClient::with_timeout( + stream, + handle, + std::time::Duration::from_secs(5), + None); + let (client, bg) = match client.await { + Err(e) => { + println!("Failed to connect to DNS server {:#?}", e); + return Outcome::Failure((Status::InternalServerError, ())) + }, + Ok(c) => c + }; + task::spawn(bg); + Outcome::Success(DnsClient(client)) + } +} + +// Reimplement this type here as ClientReponse in trust-dns crate have private fields +pub struct ClientResponse(pub(crate) R) +where + R: Future> + Send + Unpin + 'static; + +impl Future for ClientResponse +where + R: Future> + Send + Unpin + 'static, +{ + type Output = Result; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + // This is from the future_utils crate, we simply reuse the reexport from Rocket + rocket::futures::FutureExt::poll_unpin(&mut self.0, cx).map_err(ClientError::from) + } +} \ No newline at end of file diff --git a/src/dns/message.rs b/src/dns/message.rs new file mode 100644 index 0000000..1e1e62f --- /dev/null +++ b/src/dns/message.rs @@ -0,0 +1,67 @@ +use trust_dns_proto::DnsHandle; +use trust_dns_client::rr::{DNSClass, RecordType}; +use trust_dns_client::op::{UpdateMessage, OpCode, MessageType, Message, Query}; +use trust_dns_proto::error::ProtoError; + +use super::trust_dns_types::{Name, Record}; +use super::client::{ClientResponse}; + + +pub enum MessageError { + RecordNotInZone { + zone: Name, + class: DNSClass, + mismatched_class: Vec, + mismatched_zone: Vec, + } +} + + +pub trait DnsMessage: DnsHandle + Send { + fn add_records(&mut self, zone: Name, class: DNSClass, new_records: Vec) -> Result, MessageError> + { + let mut mismatched_class = Vec::new(); + let mut mismatched_zone = Vec::new(); + + for record in new_records.iter() { + if !zone.zone_of(record.name()) { + mismatched_zone.push(record.clone()); + } + if record.dns_class() != class { + mismatched_class.push(record.clone()); + } + } + + if mismatched_class.len() > 0 || mismatched_zone.len() > 0 { + return Err(MessageError::RecordNotInZone { + zone, + class, + mismatched_zone, + mismatched_class + }) + } + + let mut zone_query = Query::new(); + zone_query.set_name(zone.clone()) + .set_query_class(class) + .set_query_type(RecordType::SOA); + let mut message = Message::new(); + + // TODO: set random / time based id + message + .set_id(0) + .set_message_type(MessageType::Query) + .set_op_code(OpCode::Update) + .set_recursion_desired(false); + message.add_zone(zone_query); + message.add_updates(new_records); + + { + let edns = message.edns_mut(); + edns.set_max_payload(1232); + edns.set_version(0); + } + + return Ok(ClientResponse(self.send(message))); + } +} \ No newline at end of file diff --git a/src/dns/mod.rs b/src/dns/mod.rs new file mode 100644 index 0000000..8143b60 --- /dev/null +++ b/src/dns/mod.rs @@ -0,0 +1,16 @@ +pub mod class; +pub mod name; +pub mod rdata; +pub mod record; +pub mod client; +pub mod message; + +pub mod trust_dns_types { + pub use trust_dns_client::rr::rdata::{ + DNSSECRData, caa, sshfp, mx, null, soa, srv, txt + }; + pub use trust_dns_client::rr::{ + RData, DNSClass, Record + }; + pub use trust_dns_proto::rr::Name; +} \ No newline at end of file diff --git a/src/dns/name.rs b/src/dns/name.rs new file mode 100644 index 0000000..4dca8e8 --- /dev/null +++ b/src/dns/name.rs @@ -0,0 +1,72 @@ +use std::ops::Deref; + + +use rocket::request::FromParam; +use serde::{Deserialize, Serialize, Deserializer, Serializer}; +use trust_dns_proto::error::ProtoError; + +use super::trust_dns_types::Name; + + +#[derive(Debug, Clone)] +pub struct SerdeName(pub(crate)Name); + +impl Deref for SerdeName { + type Target = Name; + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl<'de> Deserialize<'de> for SerdeName { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de> + { + use serde::de::Error; + + String::deserialize(deserializer) + .and_then(|string| + Name::from_utf8(&string) + .map_err(|e| Error::custom(e.to_string())) + ).map( SerdeName) + } +} + +impl Serialize for SerdeName { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer + { + self.0.to_utf8().serialize(serializer) + } +} + +impl SerdeName { + pub fn into_inner(self) -> Name { + self.0 + } +} + + +#[derive(Debug, Deserialize)] +pub struct AbsoluteName(SerdeName); + +impl<'r> FromParam<'r> for AbsoluteName { + type Error = ProtoError; + + fn from_param(param: &'r str) -> Result { + let mut name = Name::from_utf8(¶m)?; + if !name.is_fqdn() { + name.set_fqdn(true); + } + Ok(AbsoluteName(SerdeName(name))) + } +} + +impl Deref for AbsoluteName { + type Target = Name; + fn deref(&self) -> &Self::Target { + &self.0.0 + } +} \ No newline at end of file diff --git a/src/models/dns.rs b/src/dns/rdata.rs similarity index 63% rename from src/models/dns.rs rename to src/dns/rdata.rs index c9d13b8..99f53af 100644 --- a/src/models/dns.rs +++ b/src/dns/rdata.rs @@ -1,21 +1,14 @@ -use std::{convert::{TryFrom, TryInto}, future::Future, net::{Ipv6Addr, Ipv4Addr}, pin::Pin, task::{Context, Poll}}; use std::fmt; -use std::ops::{Deref, DerefMut}; +use std::convert::TryFrom; +use std::net::{Ipv6Addr, Ipv4Addr}; +use serde::{Deserialize, Serialize}; -use rocket::{Request, State, http::Status, request::{FromParam, FromRequest, Outcome}}; +use trust_dns_client::serialize::binary::BinEncoder; +use trust_dns_proto::error::ProtoError; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -use tokio::{net::TcpStream as TokioTcpStream, task}; - -use trust_dns_client::{client::AsyncClient, error::ClientError, op::DnsResponse, serialize::binary::BinEncoder, tcp::TcpClientStream}; -use trust_dns_proto::error::{ProtoError}; -use trust_dns_proto::iocompat::AsyncIoTokioAsStd; - - -use super::trust_dns_types::{self, Name}; -use crate::config::Config; +use super::trust_dns_types; +use super::name::SerdeName; #[derive(Deserialize, Serialize, Clone)] @@ -103,6 +96,7 @@ pub enum RData { // ZERO, // TODO: DS + // TODO: TLSA } impl From for RData { @@ -286,195 +280,3 @@ impl<'a> fmt::Display for CAAValue<'a> { Ok(()) } } - -#[derive(Deserialize, Serialize, Clone)] -pub enum DNSClass { - IN, - CH, - HS, - NONE, - ANY, - OPT(u16), -} - -impl From for DNSClass { - fn from(dns_class: trust_dns_types::DNSClass) -> DNSClass { - match dns_class { - trust_dns_types::DNSClass::IN => DNSClass::IN, - trust_dns_types::DNSClass::CH => DNSClass::CH, - trust_dns_types::DNSClass::HS => DNSClass::HS, - trust_dns_types::DNSClass::NONE => DNSClass::NONE, - trust_dns_types::DNSClass::ANY => DNSClass::ANY, - trust_dns_types::DNSClass::OPT(v) => DNSClass::OPT(v), - } - } -} - -impl From for trust_dns_types::DNSClass { - fn from(dns_class: DNSClass) -> trust_dns_types::DNSClass { - match dns_class { - DNSClass::IN => trust_dns_types::DNSClass::IN, - DNSClass::CH => trust_dns_types::DNSClass::CH, - DNSClass::HS => trust_dns_types::DNSClass::HS, - DNSClass::NONE => trust_dns_types::DNSClass::NONE, - DNSClass::ANY => trust_dns_types::DNSClass::ANY, - DNSClass::OPT(v) => trust_dns_types::DNSClass::OPT(v), - } - } -} - - -// Reimplement this type here as ClientReponse in trust-dns crate have private fields -pub struct ClientResponse(pub(crate) R) -where - R: Future> + Send + Unpin + 'static; - -impl Future for ClientResponse -where - R: Future> + Send + Unpin + 'static, -{ - type Output = Result; - - fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - // This is from the future_utils crate, we simply reuse the reexport from Rocket - rocket::futures::FutureExt::poll_unpin(&mut self.0, cx).map_err(ClientError::from) - } -} - - - -#[derive(Deserialize, Serialize, Clone)] -pub struct Record { - #[serde(rename = "Name")] - pub name: SerdeName, - // TODO: Make class optional, default to IN - #[serde(rename = "Class")] - pub dns_class: DNSClass, - #[serde(rename = "TTL")] - pub ttl: u32, - #[serde(flatten)] - pub rdata: RData, -} - -impl From for Record { - fn from(record: trust_dns_types::Record) -> Record { - Record { - name: SerdeName(record.name().clone()), - dns_class: record.dns_class().into(), - ttl: record.ttl(), - rdata: record.into_data().into(), - } - } -} - -impl TryFrom for trust_dns_types::Record { - type Error = ProtoError; - - fn try_from(record: Record) -> Result { - let mut trust_dns_record = trust_dns_types::Record::from_rdata(record.name.into_inner(), record.ttl, record.rdata.try_into()?); - trust_dns_record.set_dns_class(record.dns_class.into()); - Ok(trust_dns_record) - } -} - -#[derive(Debug, Clone)] -pub struct SerdeName(Name); - -impl<'de> Deserialize<'de> for SerdeName { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de> - { - use serde::de::Error; - - String::deserialize(deserializer) - .and_then(|string| - Name::from_utf8(&string) - .map_err(|e| Error::custom(e.to_string())) - ).map( SerdeName) - } -} - -impl Deref for SerdeName { - type Target = Name; - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl Serialize for SerdeName { - fn serialize(&self, serializer: S) -> Result - where - S: Serializer - { - self.0.to_utf8().serialize(serializer) - } -} - -impl SerdeName { - fn into_inner(self) -> Name { - self.0 - } -} - - -#[derive(Debug, Deserialize)] -pub struct AbsoluteName(SerdeName); - -impl<'r> FromParam<'r> for AbsoluteName { - type Error = ProtoError; - - fn from_param(param: &'r str) -> Result { - let mut name = Name::from_utf8(¶m)?; - if !name.is_fqdn() { - name.set_fqdn(true); - } - Ok(AbsoluteName(SerdeName(name))) - } -} - -impl Deref for AbsoluteName { - type Target = Name; - fn deref(&self) -> &Self::Target { - &self.0.0 - } -} -pub struct DnsClient(AsyncClient); - -impl Deref for DnsClient { - type Target = AsyncClient; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl DerefMut for DnsClient { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 - } -} - - -#[rocket::async_trait] -impl<'r> FromRequest<'r> for DnsClient { - type Error = (); - async fn from_request(request: &'r Request<'_>) -> Outcome { - let config = try_outcome!(request.guard::>().await); - let (stream, handle) = TcpClientStream::>::new(config.dns.server); - let client = AsyncClient::with_timeout( - stream, - handle, - std::time::Duration::from_secs(5), - None); - let (client, bg) = match client.await { - Err(e) => { - println!("Failed to connect to DNS server {:#?}", e); - return Outcome::Failure((Status::InternalServerError, ())) - }, - Ok(c) => c - }; - task::spawn(bg); - Outcome::Success(DnsClient(client)) - } -} diff --git a/src/dns/record.rs b/src/dns/record.rs new file mode 100644 index 0000000..dc1debb --- /dev/null +++ b/src/dns/record.rs @@ -0,0 +1,43 @@ +use std::convert::{TryFrom, TryInto}; +use serde::{Deserialize, Serialize}; +use trust_dns_proto::error::ProtoError; + +use super::trust_dns_types; +use super::name::SerdeName; +use super::class::DNSClass; +use super::rdata::RData; + + +#[derive(Deserialize, Serialize, Clone)] +pub struct Record { + #[serde(rename = "Name")] + pub name: SerdeName, + // TODO: Make class optional, default to IN + #[serde(rename = "Class")] + pub dns_class: DNSClass, + #[serde(rename = "TTL")] + pub ttl: u32, + #[serde(flatten)] + pub rdata: RData, +} + +impl From for Record { + fn from(record: trust_dns_types::Record) -> Record { + Record { + name: SerdeName(record.name().clone()), + dns_class: record.dns_class().into(), + ttl: record.ttl(), + rdata: record.into_data().into(), + } + } +} + +impl TryFrom for trust_dns_types::Record { + type Error = ProtoError; + + fn try_from(record: Record) -> Result { + let mut trust_dns_record = trust_dns_types::Record::from_rdata(record.name.into_inner(), record.ttl, record.rdata.try_into()?); + trust_dns_record.set_dns_class(record.dns_class.into()); + Ok(trust_dns_record) + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index e4ed571..03aea0b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod models; mod config; mod schema; mod routes; +mod dns; use routes::users::*; use routes::zones::*; diff --git a/src/models/auth.rs b/src/models/auth.rs new file mode 100644 index 0000000..05fca5e --- /dev/null +++ b/src/models/auth.rs @@ -0,0 +1,63 @@ +use uuid::Uuid; +use serde::{Serialize, Deserialize}; +use chrono::serde::ts_seconds; +use chrono::prelude::{DateTime, Utc}; +use chrono::Duration; +use jsonwebtoken::{ + encode, decode, + Header, Validation, + Algorithm as JwtAlgorithm, EncodingKey, DecodingKey, + errors::Result as JwtResult +}; + +use crate::models::user::UserInfo; + + + +#[derive(Debug, Serialize, Deserialize)] +pub struct AuthClaims { + pub jti: String, + pub sub: String, + #[serde(with = "ts_seconds")] + pub exp: DateTime, + #[serde(with = "ts_seconds")] + pub iat: DateTime, +} + +#[derive(Debug, Serialize)] +pub struct AuthTokenResponse { + pub token: String +} + +#[derive(Debug, Deserialize)] +pub struct AuthTokenRequest { + pub username: String, + pub password: String, +} + +impl AuthClaims { + pub fn new(user_info: &UserInfo, token_duration: Duration) -> AuthClaims { + let jti = Uuid::new_v4().to_simple().to_string(); + let iat = Utc::now(); + let exp = iat + token_duration; + + AuthClaims { + jti, + sub: user_info.id.clone(), + exp, + iat, + } + } + + pub fn decode(token: &str, secret: &str) -> JwtResult { + decode::( + token, + &DecodingKey::from_secret(secret.as_ref()), + &Validation::new(JwtAlgorithm::HS256) + ).map(|data| data.claims) + } + + pub fn encode(self, secret: &str) -> JwtResult { + encode(&Header::default(), &self, &EncodingKey::from_secret(secret.as_ref())) + } +} \ No newline at end of file diff --git a/src/models/errors.rs b/src/models/errors.rs index 589d10d..c7edd76 100644 --- a/src/models/errors.rs +++ b/src/models/errors.rs @@ -3,8 +3,36 @@ use rocket::http::Status; use rocket::request::{Request, Outcome}; use rocket::response::{self, Response, Responder}; use rocket_contrib::json::Json; -use crate::models::users::UserError; use serde_json::Value; +use djangohashers::{HasherError}; +use diesel::result::Error as DieselError; + + +#[derive(Debug)] +pub enum UserError { + ZoneNotFound, + NotFound, + UserConflict, + BadCreds, + BadToken, + ExpiredToken, + MalformedHeader, + PermissionDenied, + DbError(DieselError), + PasswordError(HasherError), +} + +impl From for UserError { + fn from(e: HasherError) -> Self { + UserError::PasswordError(e) + } +} + +impl From for UserError { + fn from(e: DieselError) -> Self { + UserError::DbError(e) + } +} #[derive(Serialize, Debug)] diff --git a/src/models/mod.rs b/src/models/mod.rs index a43b162..707a5da 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,13 +1,5 @@ -pub mod dns; +//pub mod dns; pub mod errors; -pub mod users; - -pub mod trust_dns_types { - pub use trust_dns_client::rr::rdata::{ - DNSSECRData, caa, sshfp, mx, null, soa, srv, txt - }; - pub use trust_dns_client::rr::{ - RData, DNSClass, Record - }; - pub use trust_dns_proto::rr::Name; -} +pub mod user; +pub mod zone; +pub mod auth; diff --git a/src/models/users.rs b/src/models/user.rs similarity index 61% rename from src/models/users.rs rename to src/models/user.rs index d19142c..95db8e8 100644 --- a/src/models/users.rs +++ b/src/models/user.rs @@ -3,25 +3,20 @@ use diesel::prelude::*; use diesel::result::Error as DieselError; use diesel_derive_enum::DbEnum; use rocket::{State, request::{FromRequest, Request, Outcome}}; -use serde::{Serialize, Deserialize}; -use chrono::serde::ts_seconds; -use chrono::prelude::{DateTime, Utc}; -use chrono::Duration; +use serde::{Deserialize}; // TODO: Maybe just use argon2 crate directly -use djangohashers::{make_password_with_algorithm, check_password, HasherError, Algorithm}; +use djangohashers::{make_password_with_algorithm, check_password, Algorithm}; use jsonwebtoken::{ - encode, decode, - Header, Validation, - Algorithm as JwtAlgorithm, EncodingKey, DecodingKey, - errors::Result as JwtResult, errors::ErrorKind as JwtErrorKind }; use crate::schema::*; use crate::DbConn; use crate::config::Config; -use crate::models::errors::{ErrorResponse, make_500}; -use crate::models::dns::AbsoluteName; +use crate::models::errors::{UserError, ErrorResponse, make_500}; +use crate::models::zone::Zone; +use crate::models::auth::AuthClaims; + const BEARER: &str = "Bearer "; const AUTH_HEADER: &str = "Authorization"; @@ -61,14 +56,6 @@ pub struct UserZone { pub zone_id: String, } -#[derive(Debug, Serialize, Queryable, Identifiable, Insertable)] -#[table_name = "zone"] -pub struct Zone { - #[serde(skip)] - pub id: String, - pub name: String, -} - #[derive(Debug, Deserialize)] pub struct CreateUserRequest { pub username: String, @@ -77,42 +64,6 @@ pub struct CreateUserRequest { pub role: Option } -#[derive(Debug, Deserialize)] -pub struct AddZoneMemberRequest { - pub id: String, -} - -#[derive(Debug, Deserialize)] -pub struct CreateZoneRequest { - pub name: AbsoluteName, -} - -// pub struct LdapUserAssociation { -// user_id: Uuid, -// ldap_id: String -// } - -#[derive(Debug, Serialize, Deserialize)] -pub struct AuthClaims { - pub jti: String, - pub sub: String, - #[serde(with = "ts_seconds")] - pub exp: DateTime, - #[serde(with = "ts_seconds")] - pub iat: DateTime, -} - -#[derive(Debug, Serialize)] -pub struct AuthTokenResponse { - pub token: String -} - -#[derive(Debug, Deserialize)] -pub struct AuthTokenRequest { - pub username: String, - pub password: String, -} - #[derive(Debug)] pub struct UserInfo { pub id: String, @@ -205,32 +156,6 @@ impl<'r> FromRequest<'r> for UserInfo { } } -#[derive(Debug)] -pub enum UserError { - ZoneNotFound, - NotFound, - UserConflict, - BadCreds, - BadToken, - ExpiredToken, - MalformedHeader, - PermissionDenied, - DbError(DieselError), - PasswordError(HasherError), -} - -impl From for UserError { - fn from(e: HasherError) -> Self { - UserError::PasswordError(e) - } -} - -impl From for UserError { - fn from(e: DieselError) -> Self { - UserError::DbError(e) - } -} - impl LocalUser { pub fn create_user(conn: &diesel::SqliteConnection, user_request: CreateUserRequest) -> Result { use crate::schema::localuser::dsl::*; @@ -320,95 +245,4 @@ impl LocalUser { username: client_localuser.username, }) } - -} - -impl AuthClaims { - pub fn new(user_info: &UserInfo, token_duration: Duration) -> AuthClaims { - let jti = Uuid::new_v4().to_simple().to_string(); - let iat = Utc::now(); - let exp = iat + token_duration; - - AuthClaims { - jti, - sub: user_info.id.clone(), - exp, - iat, - } - } - - pub fn decode(token: &str, secret: &str) -> JwtResult { - decode::( - token, - &DecodingKey::from_secret(secret.as_ref()), - &Validation::new(JwtAlgorithm::HS256) - ).map(|data| data.claims) - } - - pub fn encode(self, secret: &str) -> JwtResult { - encode(&Header::default(), &self, &EncodingKey::from_secret(secret.as_ref())) - } -} - -// NOTE: Should probably not be implemented here -// also, "UserError" seems like a misleading name -impl Zone { - pub fn get_all(conn: &diesel::SqliteConnection) -> Result, UserError> { - use crate::schema::zone::dsl::*; - - zone.get_results(conn) - .map_err(UserError::DbError) - } - - pub fn get_by_name(conn: &diesel::SqliteConnection, zone_name: &str) -> Result { - use crate::schema::zone::dsl::*; - - zone.filter(name.eq(zone_name)) - .get_result(conn) - .map_err(|e| match e { - DieselError::NotFound => UserError::ZoneNotFound, - other => UserError::DbError(other) - }) - } - - pub fn create_zone(conn: &diesel::SqliteConnection, zone_request: CreateZoneRequest) -> Result { - use crate::schema::zone::dsl::*; - - let new_zone = Zone { - id: Uuid::new_v4().to_simple().to_string(), - name: zone_request.name.to_utf8(), - }; - - diesel::insert_into(zone) - .values(&new_zone) - .execute(conn) - .map_err(|e| match e { - DieselError::DatabaseError(diesel::result::DatabaseErrorKind::UniqueViolation, _) => UserError::UserConflict, - other => UserError::DbError(other) - })?; - Ok(new_zone) - } - - - pub fn add_member(&self, conn: &diesel::SqliteConnection, new_member: &UserInfo) -> Result<(), UserError> { - use crate::schema::user_zone::dsl::*; - - let new_user_zone = UserZone { - zone_id: self.id.clone(), - user_id: new_member.id.clone() - }; - - let res = diesel::insert_into(user_zone) - .values(new_user_zone) - .execute(conn); - - match res { - // If user has already access to the zone, safely ignore the conflit - // TODO: use 'on conflict do nothing' in postgres when we get there - Err(DieselError::DatabaseError(diesel::result::DatabaseErrorKind::UniqueViolation, _)) => (), - Err(e) => return Err(e.into()), - Ok(_) => () - }; - Ok(()) - } -} +} \ No newline at end of file diff --git a/src/models/zone.rs b/src/models/zone.rs new file mode 100644 index 0000000..47e62d1 --- /dev/null +++ b/src/models/zone.rs @@ -0,0 +1,93 @@ +use crate::models::user::UserInfo; + +use uuid::Uuid; +use diesel::prelude::*; +use diesel::result::Error as DieselError; +use serde::{Serialize, Deserialize}; + +use crate::schema::*; +use crate::dns::name::AbsoluteName; +use crate::models::user::UserZone; +use crate::models::errors::UserError; + + +#[derive(Debug, Serialize, Queryable, Identifiable, Insertable)] +#[table_name = "zone"] +pub struct Zone { + #[serde(skip)] + pub id: String, + pub name: String, +} + +#[derive(Debug, Deserialize)] +pub struct AddZoneMemberRequest { + pub id: String, +} + +#[derive(Debug, Deserialize)] +pub struct CreateZoneRequest { + pub name: AbsoluteName, +} + +// NOTE: Should probably not be implemented here +// also, "UserError" seems like a misleading name +impl Zone { + pub fn get_all(conn: &diesel::SqliteConnection) -> Result, UserError> { + use crate::schema::zone::dsl::*; + + zone.get_results(conn) + .map_err(UserError::DbError) + } + + pub fn get_by_name(conn: &diesel::SqliteConnection, zone_name: &str) -> Result { + use crate::schema::zone::dsl::*; + + zone.filter(name.eq(zone_name)) + .get_result(conn) + .map_err(|e| match e { + DieselError::NotFound => UserError::ZoneNotFound, + other => UserError::DbError(other) + }) + } + + pub fn create_zone(conn: &diesel::SqliteConnection, zone_request: CreateZoneRequest) -> Result { + use crate::schema::zone::dsl::*; + + let new_zone = Zone { + id: Uuid::new_v4().to_simple().to_string(), + name: zone_request.name.to_utf8(), + }; + + diesel::insert_into(zone) + .values(&new_zone) + .execute(conn) + .map_err(|e| match e { + DieselError::DatabaseError(diesel::result::DatabaseErrorKind::UniqueViolation, _) => UserError::UserConflict, + other => UserError::DbError(other) + })?; + Ok(new_zone) + } + + + pub fn add_member(&self, conn: &diesel::SqliteConnection, new_member: &UserInfo) -> Result<(), UserError> { + use crate::schema::user_zone::dsl::*; + + let new_user_zone = UserZone { + zone_id: self.id.clone(), + user_id: new_member.id.clone() + }; + + let res = diesel::insert_into(user_zone) + .values(new_user_zone) + .execute(conn); + + match res { + // If user has already access to the zone, safely ignore the conflit + // TODO: use 'on conflict do nothing' in postgres when we get there + Err(DieselError::DatabaseError(diesel::result::DatabaseErrorKind::UniqueViolation, _)) => (), + Err(e) => return Err(e.into()), + Ok(_) => () + }; + Ok(()) + } +} \ No newline at end of file diff --git a/src/routes/users.rs b/src/routes/users.rs index e6967b3..524d801 100644 --- a/src/routes/users.rs +++ b/src/routes/users.rs @@ -5,13 +5,8 @@ use rocket::http::Status; use crate::config::Config; use crate::DbConn; use crate::models::errors::{ErrorResponse, make_500}; -use crate::models::users::{ - LocalUser, - CreateUserRequest, - AuthClaims, - AuthTokenRequest, - AuthTokenResponse -}; +use crate::models::user::{LocalUser, CreateUserRequest}; +use crate::models::auth::{AuthClaims, AuthTokenRequest, AuthTokenResponse}; #[post("/users/me/token", data = "")] diff --git a/src/routes/zones.rs b/src/routes/zones.rs index ae7ddeb..d251543 100644 --- a/src/routes/zones.rs +++ b/src/routes/zones.rs @@ -5,27 +5,30 @@ use rocket::http::Status; use rocket_contrib::json::Json; -use trust_dns_client::{client::ClientHandle, op::UpdateMessage}; +use trust_dns_client::client::ClientHandle; use trust_dns_client::op::ResponseCode; use trust_dns_client::rr::{DNSClass, RecordType}; -use trust_dns_proto::DnsHandle; + pub use trust_dns_client::op::Message; pub use trust_dns_client::op::OpCode; pub use trust_dns_client::op::Query; pub use trust_dns_client::op::MessageType; -use crate::{DbConn, models::{dns, trust_dns_types}}; +use crate::{dns::{self, trust_dns_types}, DbConn}; use crate::models::errors::{ErrorResponse, make_500}; -use crate::models::users::{LocalUser, UserInfo, Zone, AddZoneMemberRequest, CreateZoneRequest}; +use crate::models::user::{LocalUser, UserInfo}; +use crate::models::zone::{Zone, AddZoneMemberRequest, CreateZoneRequest}; +use crate::dns::message::DnsMessage; +use crate::dns::message::MessageError; #[get("/zones//records")] pub async fn get_zone_records( - mut client: dns::DnsClient, + mut client: dns::client::DnsClient, conn: DbConn, user_info: Result, - zone: dns::AbsoluteName -) -> Result>, ErrorResponse> { + zone: dns::name::AbsoluteName +) -> Result>, ErrorResponse> { let user_info = user_info?; let zone_name = zone.to_string(); @@ -55,8 +58,8 @@ pub async fn get_zone_records( let answers = response.answers(); let mut records: Vec<_> = answers.to_vec().into_iter() - .map(dns::Record::from) - .filter(|record| !matches!(record.rdata, dns::RData::NULL { .. } | dns::RData::DNSSEC(_))) + .map(dns::record::Record::from) + .filter(|record| !matches!(record.rdata, dns::rdata::RData::NULL { .. } | dns::rdata::RData::DNSSEC(_))) .collect(); // AXFR response ends with SOA, we remove it so it is not doubled in the response. @@ -67,11 +70,11 @@ pub async fn get_zone_records( #[post("/zones//records", data = "")] pub async fn create_zone_records( - mut client: dns::DnsClient, + mut client: dns::client::DnsClient, conn: DbConn, user_info: Result, - zone: dns::AbsoluteName, - new_records: Json> + zone: dns::name::AbsoluteName, + new_records: Json> ) -> Result, ErrorResponse> { let user_info = user_info?; @@ -98,10 +101,6 @@ pub async fn create_zone_records( } } - let bad_zone_records: Vec<_> = records.iter().filter(|record| !zone.zone_of(record.name())).collect(); - // TODO: Get zone class from somewhere instead of always assuming IN - let bad_class_records: Vec<_> = records.iter().filter(|record| record.dns_class() != DNSClass::IN).collect(); - if !bad_records.is_empty() { return ErrorResponse::new( Status::BadRequest, @@ -114,54 +113,21 @@ pub async fn create_zone_records( ).err(); } - if !bad_zone_records.is_empty() { - return ErrorResponse::new( - Status::BadRequest, - "Record list contains records whose name does not belong to the zone".into() - ).with_details( - json!({ - "zone_name": zone.to_utf8(), - "records": bad_zone_records.into_iter().map(|r| r.clone().into()).collect::>() - }) - ).err(); - } - - if !bad_class_records.is_empty() { - return ErrorResponse::new( - Status::BadRequest, - "Record list contains records whose class differs from the zone class `IN`".into() - ).with_details( - json!({ - "zone_name": zone.to_utf8(), - "records": bad_class_records.into_iter().map(|r| r.clone().into()).collect::>() - }) - ).err(); - } - - let mut zone_query = Query::new(); - zone_query.set_name(zone.clone()) - .set_query_class(DNSClass::IN) - .set_query_type(RecordType::SOA); - let mut message = Message::new(); - - // TODO: set random / time based id - message - .set_id(0) - .set_message_type(MessageType::Query) - .set_op_code(OpCode::Update) - .set_recursion_desired(false); - message.add_zone(zone_query); - message.add_updates(records); - - { - let edns = message.edns_mut(); - edns.set_max_payload(1232); - edns.set_version(0); - } - - let response = { - let query = dns::ClientResponse(client.send(message)); - query.await.map_err(make_500)? + let response = match client.add_records(zone.clone(), DNSClass::IN, records) { + Ok(query) => query.await.map_err(make_500)?, + Err(MessageError::RecordNotInZone { zone, class, mismatched_class, mismatched_zone}) => { + return ErrorResponse::new( + Status::BadRequest, + "Record list contains records that do not belong to the zone".into() + ).with_details( + json!({ + "zone_name": zone.to_utf8(), + "class": dns::class::DNSClass::from(class), + "mismatched_class": mismatched_class.into_iter().map(|r| r.clone().into()).collect::>(), + "mismatched_zone": mismatched_zone.into_iter().map(|r| r.clone().into()).collect::>(), + }) + ).err(); + } }; // TODO: better error handling @@ -199,7 +165,7 @@ pub async fn get_zones( #[post("/zones", data = "")] pub async fn create_zone( conn: DbConn, - mut client: dns::DnsClient, + mut client: dns::client::DnsClient, user_info: Result, zone_request: Json, ) -> Result, ErrorResponse> { @@ -230,7 +196,7 @@ pub async fn create_zone( #[post("/zones//members", data = "")] pub async fn add_member_to_zone<'r>( conn: DbConn, - zone: dns::AbsoluteName, + zone: dns::name::AbsoluteName, user_info: Result, zone_member_request: Json ) -> Result, ErrorResponse> { diff --git a/src/schema.rs b/src/schema.rs index 775c738..10c2d36 100644 --- a/src/schema.rs +++ b/src/schema.rs @@ -1,6 +1,6 @@ table! { use diesel::sql_types::*; - use crate::models::users::*; + use crate::models::user::*; localuser (user_id) { user_id -> Text, From 690987010df240e2d93840cfeac639048c4431e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Berthaud-M=C3=BCller?= Date: Fri, 4 Mar 2022 13:08:47 +0100 Subject: [PATCH 06/19] add e2e tests --- api.yml | 402 +++++++++++++++++++++++++++++++++++++++++++++++++++ e2e/zones.py | 99 +++++++++++++ 2 files changed, 501 insertions(+) create mode 100644 api.yml create mode 100644 e2e/zones.py diff --git a/api.yml b/api.yml new file mode 100644 index 0000000..5d8e720 --- /dev/null +++ b/api.yml @@ -0,0 +1,402 @@ +openapi: '3.0.0' +info: + description: '' + version: 0.1.0-dev + title: Nomilo + + +components: + securitySchemes: + ApiToken: + type: http + scheme: bearer + bearerFormat: JWT + + parameters: + ZoneName: + name: zone + in: path + schema: + type: string + required: true + + schemas: + UserRequest: + type: object + required: + - username + - password + - email + properties: + username: + type: string + password: + type: string + email: + type: string + role: + type: string + enum: + - admin + - zoneadmin + + TokenRequest: + type: object + required: + - username + - password + properties: + username: + type: string + password: + type: string + + TokenResponse: + type: object + required: + - token + properties: + token: + type: string + + AddZoneMemberRequest: + type: object + required: + - id + properties: + id: + type: string + + CreateZoneRequest: + type: object + required: + - name + properties: + name: + type: string + + Zone: + type: object + required: + - name + properties: + name: + type: string + + ZoneList: + type: array + items: + $ref: '#/components/schemas/Zone' + + RecordBase: + type: object + required: + - Name + - Class + - TTL + - Type + properties: + Name: + type: string + Class: + type: string + enum: + - IN + - CH + - HS + - NONE + - ANY + TTL: + type: integer + Type: + type: string + + RecordTypeA: + type: object + allOf: + - $ref: '#/components/schemas/RecordBase' + - type: object + required: + - Address + properties: + Address: + type: string + + RecordTypeAAAA: + type: object + allOf: + - $ref: '#/components/schemas/RecordBase' + - type: object + required: + - Address + properties: + Address: + type: string + + RecordTypeCAA: + type: object + allOf: + - $ref: '#/components/schemas/RecordBase' + required: + - IssuerCritical + - Value + - PropertyTag + properties: + IssuerCritical: + type: boolean + Value: + type: string + PropertyTag: + type: string + + RecordTypeCNAME: + type: object + allOf: + - $ref: '#/components/schemas/RecordBase' + - type: object + required: + - Target + properties: + Target: + type: string + + RecordTypeMX: + type: object + allOf: + - $ref: '#/components/schemas/RecordBase' + - type: object + required: + - Preference + - MailExchanger + properties: + Preference: + type: integer + MailExchanger: + type: string + + RecordTypeNS: + type: object + allOf: + - $ref: '#/components/schemas/RecordBase' + - type: object + required: + - Target + properties: + Target: + type: string + + RecordTypePTR: + type: object + allOf: + - $ref: '#/components/schemas/RecordBase' + - type: object + required: + - Target + properties: + Target: + type: string + + RecordTypeSOA: + type: object + allOf: + - $ref: '#/components/schemas/RecordBase' + - type: object + required: + - MasterServerName + - MaintainerName + - Refresh + - Retry + - Expire + - Minimum + - Serial + properties: + MasterServerName: + type: string + MaintainerName: + type: string + Refresh: + type: integer + Retry: + type: integer + Expire: + type: integer + Minimum: + type: integer + Serial: + type: integer + + RecordTypeSRV: + type: object + allOf: + - $ref: '#/components/schemas/RecordBase' + - type: object + required: + - Server + - Port + - Priority + - Weight + properties: + Server: + type: string + Port: + type: integer + Priority: + type: integer + Weight: + type: integer + + RecordTypeSSHFP: + type: object + allOf: + - $ref: '#/components/schemas/RecordBase' + - type: object + required: + - Algorithm + - DigestType + - Fingerprint + properties: + Algorithm: + type: integer + DigestType: + type: integer + Fingerprint: + type: string + + RecordTypeTXT: + type: object + allOf: + - $ref: '#/components/schemas/RecordBase' + - type: object + required: + - Text + properties: + Text: + type: string + + Record: + type: object + oneOf: + - $ref: '#/components/schemas/RecordTypeA' + - $ref: '#/components/schemas/RecordTypeAAAA' + - $ref: '#/components/schemas/RecordTypeCAA' + - $ref: '#/components/schemas/RecordTypeCNAME' + - $ref: '#/components/schemas/RecordTypeMX' + - $ref: '#/components/schemas/RecordTypeNS' + - $ref: '#/components/schemas/RecordTypePTR' + - $ref: '#/components/schemas/RecordTypeSOA' + - $ref: '#/components/schemas/RecordTypeSRV' + - $ref: '#/components/schemas/RecordTypeSSHFP' + - $ref: '#/components/schemas/RecordTypeTXT' + discriminator: + propertyName: Type + mapping: + A: '#/components/schemas/RecordTypeA' + AAAA: '#/components/schemas/RecordTypeAAAA' + CAA: '#/components/schemas/RecordTypeCAA' + CNAME: '#/components/schemas/RecordTypeCNAME' + MX: '#/components/schemas/RecordTypeMX' + NS: '#/components/schemas/RecordTypeNS' + PTR: '#/components/schemas/RecordTypePTR' + SOA: '#/components/schemas/RecordTypeSOA' + SRV: '#/components/schemas/RecordTypeSRV' + SSHFP: '#/components/schemas/RecordTypeSSHFP' + TXT: '#/components/schemas/RecordTypeTXT' + + RecordList: + type: array + items: + $ref: '#/components/schemas/Record' + + +paths: + '/users': + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserRequest' + responses: + '201': + description: '' + + '/users/me/token': + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/TokenRequest' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/TokenResponse' + '/zones': + get: + security: + - ApiToken: [] + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/ZoneList' + post: + security: + - ApiToken: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateZoneRequest' + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/Zone' + + '/zones/{zone}/members': + parameters: + - $ref: '#/components/parameters/ZoneName' + post: + security: + - ApiToken: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/AddZoneMemberRequest' + responses: + '201': + description: '' + + '/zones/{zone}/records': + parameters: + - $ref: '#/components/parameters/ZoneName' + get: + security: + - ApiToken: [] + responses: + '200': + description: '' + content: + application/json: + schema: + $ref: '#/components/schemas/RecordList' + post: + security: + - ApiToken: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RecordList' + responses: + '200': + description: '' diff --git a/e2e/zones.py b/e2e/zones.py new file mode 100644 index 0000000..0e0ec3a --- /dev/null +++ b/e2e/zones.py @@ -0,0 +1,99 @@ +from nomilo_client import ApiClient, Configuration +from nomilo_client.api.default_api import DefaultApi +from nomilo_client.models import ( + TokenRequest, + RecordTypeSOA, + RecordTypeAAAA, + RecordTypeCNAME, + RecordTypeNS, + RecordTypeTXT, + RecordList +) + +import logging +import string +import random + +import unittest +import warnings + + +logging.basicConfig(level=logging.DEBUG) + +HOST = 'http://localhost:8000/api/v1' +USER='toto' +PASSWORD='supersecure' + + +def build_api(host: str): + conf = Configuration(host=HOST) + api_client = ApiClient(configuration=conf) + return DefaultApi(api_client) + +def build_authenticated_api(host: str, token: TokenRequest): + auth_conf = Configuration(host=host, access_token=token.token) + api_client = ApiClient(configuration=auth_conf) + return DefaultApi(api_client) + +def random_string(length): + return ''.join(random.choice(string.ascii_lowercase) for x in range(length)) + +def random_name(zone): + return '%s.%s' % (random_string(16), zone) + + +class TestZones(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Ignore warning about unclosed socket + warnings.filterwarnings(action="ignore", message="unclosed", category=ResourceWarning) + + api = build_api(HOST) + token = api.users_me_token_post(token_request=TokenRequest(username=USER,password=PASSWORD)) + cls.api = build_authenticated_api(HOST, token) + + def test_get_zones(self): + zones = self.api.zones_get() + zone_name = zones.value[0].name + self.assertEqual(zone_name, 'example.com.') + + def test_get_records(self): + records = self.api.zones_zone_records_get(zone='example.com.') + for record in records.value: + if type(record) is RecordTypeSOA: + with self.subTest(type='soa'): + self.assertEqual(record.name, 'example.com.') + + if type(record) is RecordTypeAAAA: + with self.subTest(type='ns'): + self.assertEqual(record.name, 'srv1.example.com.') + self.assertEqual(record.address, '2001:db8:cafe:bc68::2') + + if type(record) is RecordTypeCNAME: + with self.subTest(type='cname'): + self.assertEqual(record.name, 'www.example.com.') + self.assertEqual(record.target, 'srv1.example.com.') + + if type(record) is RecordTypeNS: + with self.subTest(type='ns'): + self.assertEqual(record.name, 'example.com.') + self.assertEqual(record.target, 'ns.example.com.') + + def test_create_records(self): + new_record = RecordTypeTXT( + _class='IN', + ttl=300, + name=random_name('example.com.'), + text=random_string(32), + type='TXT' + ) + + self.api.zones_zone_records_post(zone='example.com.', record_list=RecordList(value=[new_record])) + records = self.api.zones_zone_records_get(zone='example.com.') + found = False + for record in records.value: + if type(record) is RecordTypeTXT and record.name == new_record.name: + self.assertEqual(record.text, new_record.text, msg='New record does not have the expected value') + found = True + + self.assertTrue(found, msg='New record not found in zone records') From 094539d376eeeeea19af12696460ebf01af32001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Berthaud-M=C3=BCller?= Date: Fri, 4 Mar 2022 13:50:57 +0100 Subject: [PATCH 07/19] move record fetching in message module --- src/dns/message.rs | 36 ++++++++++++++++++++++++++++++++---- src/routes/zones.rs | 38 +++++++++++++++----------------------- 2 files changed, 47 insertions(+), 27 deletions(-) diff --git a/src/dns/message.rs b/src/dns/message.rs index 1e1e62f..f2e9c65 100644 --- a/src/dns/message.rs +++ b/src/dns/message.rs @@ -1,24 +1,52 @@ use trust_dns_proto::DnsHandle; use trust_dns_client::rr::{DNSClass, RecordType}; -use trust_dns_client::op::{UpdateMessage, OpCode, MessageType, Message, Query}; +use trust_dns_client::op::{UpdateMessage, OpCode, MessageType, Message, Query, ResponseCode}; +use trust_dns_client::error::ClientError; use trust_dns_proto::error::ProtoError; +use trust_dns_client::proto::xfer::{DnsRequestOptions}; -use super::trust_dns_types::{Name, Record}; +use super::trust_dns_types::{Name, Record, RData}; use super::client::{ClientResponse}; +#[derive(Debug)] pub enum MessageError { RecordNotInZone { zone: Name, class: DNSClass, mismatched_class: Vec, mismatched_zone: Vec, - } + }, + ClientError(ClientError), + ResponceNotOk(ResponseCode) } +#[async_trait] pub trait DnsMessage: DnsHandle + Send { - fn add_records(&mut self, zone: Name, class: DNSClass, new_records: Vec) -> Result, MessageError> + async fn get_records(&mut self, zone: Name, class: DNSClass) -> Result, MessageError> + { + let response = { + let mut query = Query::query(zone, RecordType::AXFR); + query.set_query_class(class); + ClientResponse(self.lookup(query, DnsRequestOptions::default())).await.map_err(|e| MessageError::ClientError(e))? + }; + + if response.response_code() != ResponseCode::NoError { + return Err(MessageError::ResponceNotOk(response.response_code())); + } + + let answers = response.answers(); + let mut records: Vec<_> = answers.to_vec().into_iter() + .filter(|record| !matches!(record.rdata(), RData::NULL { .. } | RData::DNSSEC(_))) + .collect(); + + // AXFR response ends with SOA, we remove it so it is not doubled in the response. + records.pop(); + Ok(records) + } + + fn add_records(&mut self, zone: Name, class: DNSClass, new_records: Vec) -> Result::Response>, MessageError> { let mut mismatched_class = Vec::new(); let mut mismatched_zone = Vec::new(); diff --git a/src/routes/zones.rs b/src/routes/zones.rs index d251543..b41e0ea 100644 --- a/src/routes/zones.rs +++ b/src/routes/zones.rs @@ -41,30 +41,21 @@ pub async fn get_zone_records( } }).await?; - let response = { - let query = client.query(zone.clone(), DNSClass::IN, RecordType::AXFR); - query.await.map_err(make_500)? + let records: Vec<_> = match client.get_records(zone.clone(), DNSClass::IN).await { + Ok(records) => records.into_iter().map(dns::record::Record::from).collect(), + + Err(MessageError::ResponceNotOk(code)) => { + println!("Querrying AXFR of zone {} failed with code {}", *zone, code); + return ErrorResponse::new( + Status::NotFound, + "Zone could not be found".into() + ).with_details(json!({ + "zone_name": zone.to_utf8() + })).err(); + }, + Err(err) => { return make_500(err).err(); }, }; - if response.response_code() != ResponseCode::NoError { - println!("Querrying AXFR of zone {} failed with code {}", *zone, response.response_code()); - return ErrorResponse::new( - Status::NotFound, - "Zone could not be found".into() - ).with_details(json!({ - "zone_name": zone.to_utf8() - })).err(); - } - - let answers = response.answers(); - let mut records: Vec<_> = answers.to_vec().into_iter() - .map(dns::record::Record::from) - .filter(|record| !matches!(record.rdata, dns::rdata::RData::NULL { .. } | dns::rdata::RData::DNSSEC(_))) - .collect(); - - // AXFR response ends with SOA, we remove it so it is not doubled in the response. - records.pop(); - Ok(Json(records)) } @@ -127,7 +118,8 @@ pub async fn create_zone_records( "mismatched_zone": mismatched_zone.into_iter().map(|r| r.clone().into()).collect::>(), }) ).err(); - } + }, + Err(e) => return make_500(e).err() }; // TODO: better error handling From d4079b0674b3da375d839951aefc35b7bf077e77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Berthaud-M=C3=BCller?= Date: Fri, 4 Mar 2022 13:59:12 +0100 Subject: [PATCH 08/19] remove unused imports --- src/routes/zones.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/routes/zones.rs b/src/routes/zones.rs index b41e0ea..25bf124 100644 --- a/src/routes/zones.rs +++ b/src/routes/zones.rs @@ -9,11 +9,6 @@ use trust_dns_client::client::ClientHandle; use trust_dns_client::op::ResponseCode; use trust_dns_client::rr::{DNSClass, RecordType}; -pub use trust_dns_client::op::Message; -pub use trust_dns_client::op::OpCode; -pub use trust_dns_client::op::Query; -pub use trust_dns_client::op::MessageType; - use crate::{dns::{self, trust_dns_types}, DbConn}; use crate::models::errors::{ErrorResponse, make_500}; use crate::models::user::{LocalUser, UserInfo}; From ae6c94e2a721c66b8ab793f355a5cfb023560b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Berthaud-M=C3=BCller?= Date: Fri, 4 Mar 2022 17:17:15 +0100 Subject: [PATCH 09/19] rearchitecture modules --- src/dns/class.rs | 40 ------------- src/dns/message.rs | 2 +- src/dns/mod.rs | 21 +++---- src/models/class.rs | 40 +++++++++++++ src/models/mod.rs | 16 +++++- src/{dns => models}/name.rs | 2 +- src/{dns => models}/rdata.rs | 104 +++++++++++++++++----------------- src/{dns => models}/record.rs | 10 ++-- src/models/zone.rs | 6 +- src/routes/users.rs | 20 +++---- src/routes/zones.rs | 79 +++++++++++++------------- 11 files changed, 173 insertions(+), 167 deletions(-) delete mode 100644 src/dns/class.rs create mode 100644 src/models/class.rs rename src/{dns => models}/name.rs (97%) rename src/{dns => models}/rdata.rs (64%) rename src/{dns => models}/record.rs (74%) diff --git a/src/dns/class.rs b/src/dns/class.rs deleted file mode 100644 index e95eab8..0000000 --- a/src/dns/class.rs +++ /dev/null @@ -1,40 +0,0 @@ -use serde::{Deserialize, Serialize}; - -use super::trust_dns_types; - - -#[derive(Deserialize, Serialize, Clone)] -pub enum DNSClass { - IN, - CH, - HS, - NONE, - ANY, - OPT(u16), -} - -impl From for DNSClass { - fn from(dns_class: trust_dns_types::DNSClass) -> DNSClass { - match dns_class { - trust_dns_types::DNSClass::IN => DNSClass::IN, - trust_dns_types::DNSClass::CH => DNSClass::CH, - trust_dns_types::DNSClass::HS => DNSClass::HS, - trust_dns_types::DNSClass::NONE => DNSClass::NONE, - trust_dns_types::DNSClass::ANY => DNSClass::ANY, - trust_dns_types::DNSClass::OPT(v) => DNSClass::OPT(v), - } - } -} - -impl From for trust_dns_types::DNSClass { - fn from(dns_class: DNSClass) -> trust_dns_types::DNSClass { - match dns_class { - DNSClass::IN => trust_dns_types::DNSClass::IN, - DNSClass::CH => trust_dns_types::DNSClass::CH, - DNSClass::HS => trust_dns_types::DNSClass::HS, - DNSClass::NONE => trust_dns_types::DNSClass::NONE, - DNSClass::ANY => trust_dns_types::DNSClass::ANY, - DNSClass::OPT(v) => trust_dns_types::DNSClass::OPT(v), - } - } -} \ No newline at end of file diff --git a/src/dns/message.rs b/src/dns/message.rs index f2e9c65..5ff620f 100644 --- a/src/dns/message.rs +++ b/src/dns/message.rs @@ -5,7 +5,7 @@ use trust_dns_client::error::ClientError; use trust_dns_proto::error::ProtoError; use trust_dns_client::proto::xfer::{DnsRequestOptions}; -use super::trust_dns_types::{Name, Record, RData}; +use super::{Name, Record, RData}; use super::client::{ClientResponse}; diff --git a/src/dns/mod.rs b/src/dns/mod.rs index 8143b60..2aa75ab 100644 --- a/src/dns/mod.rs +++ b/src/dns/mod.rs @@ -1,16 +1,11 @@ -pub mod class; -pub mod name; -pub mod rdata; -pub mod record; pub mod client; pub mod message; -pub mod trust_dns_types { - pub use trust_dns_client::rr::rdata::{ - DNSSECRData, caa, sshfp, mx, null, soa, srv, txt - }; - pub use trust_dns_client::rr::{ - RData, DNSClass, Record - }; - pub use trust_dns_proto::rr::Name; -} \ No newline at end of file +// Reexport trust dns types for convenience +pub use trust_dns_client::rr::rdata::{ + DNSSECRData, caa, sshfp, mx, null, soa, srv, txt +}; +pub use trust_dns_client::rr::{ + RData, DNSClass, Record +}; +pub use trust_dns_proto::rr::Name; \ No newline at end of file diff --git a/src/models/class.rs b/src/models/class.rs new file mode 100644 index 0000000..385588d --- /dev/null +++ b/src/models/class.rs @@ -0,0 +1,40 @@ +use serde::{Deserialize, Serialize}; + +use crate::dns; + + +#[derive(Deserialize, Serialize, Clone)] +pub enum DNSClass { + IN, + CH, + HS, + NONE, + ANY, + OPT(u16), +} + +impl From for DNSClass { + fn from(dns_class: dns::DNSClass) -> DNSClass { + match dns_class { + dns::DNSClass::IN => DNSClass::IN, + dns::DNSClass::CH => DNSClass::CH, + dns::DNSClass::HS => DNSClass::HS, + dns::DNSClass::NONE => DNSClass::NONE, + dns::DNSClass::ANY => DNSClass::ANY, + dns::DNSClass::OPT(v) => DNSClass::OPT(v), + } + } +} + +impl From for dns::DNSClass { + fn from(dns_class: DNSClass) -> dns::DNSClass { + match dns_class { + DNSClass::IN => dns::DNSClass::IN, + DNSClass::CH => dns::DNSClass::CH, + DNSClass::HS => dns::DNSClass::HS, + DNSClass::NONE => dns::DNSClass::NONE, + DNSClass::ANY => dns::DNSClass::ANY, + DNSClass::OPT(v) => dns::DNSClass::OPT(v), + } + } +} \ No newline at end of file diff --git a/src/models/mod.rs b/src/models/mod.rs index 707a5da..0b387b9 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,5 +1,19 @@ //pub mod dns; +pub mod auth; +pub mod class; pub mod errors; +pub mod name; +pub mod rdata; +pub mod record; pub mod user; pub mod zone; -pub mod auth; + +// Reexport types for convenience +pub use auth::{AuthClaims, AuthTokenRequest, AuthTokenResponse}; +pub use class::DNSClass; +pub use errors::{UserError, ErrorResponse, make_500}; +pub use name::{AbsoluteName, SerdeName}; +pub use user::{LocalUser, UserInfo, Role, UserZone, User, CreateUserRequest}; +pub use rdata::RData; +pub use record::Record; +pub use zone::{Zone, AddZoneMemberRequest, CreateZoneRequest}; \ No newline at end of file diff --git a/src/dns/name.rs b/src/models/name.rs similarity index 97% rename from src/dns/name.rs rename to src/models/name.rs index 4dca8e8..0856f11 100644 --- a/src/dns/name.rs +++ b/src/models/name.rs @@ -5,7 +5,7 @@ use rocket::request::FromParam; use serde::{Deserialize, Serialize, Deserializer, Serializer}; use trust_dns_proto::error::ProtoError; -use super::trust_dns_types::Name; +use crate::dns::Name; #[derive(Debug, Clone)] diff --git a/src/dns/rdata.rs b/src/models/rdata.rs similarity index 64% rename from src/dns/rdata.rs rename to src/models/rdata.rs index 99f53af..64c61be 100644 --- a/src/dns/rdata.rs +++ b/src/models/rdata.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; use trust_dns_client::serialize::binary::BinEncoder; use trust_dns_proto::error::ProtoError; -use super::trust_dns_types; +use crate::dns; use super::name::SerdeName; @@ -87,7 +87,7 @@ pub enum RData { // TODO: Eventually allow deserialization of DNSSEC records #[serde(skip)] - DNSSEC(trust_dns_types::DNSSECRData), + DNSSEC(dns::DNSSECRData), #[serde(rename_all = "PascalCase")] Unknown { code: u16, @@ -99,39 +99,39 @@ pub enum RData { // TODO: TLSA } -impl From for RData { - fn from(rdata: trust_dns_types::RData) -> RData { +impl From for RData { + fn from(rdata: dns::RData) -> RData { match rdata { - trust_dns_types::RData::A(address) => RData::A { address }, - trust_dns_types::RData::AAAA(address) => RData::AAAA { address }, + dns::RData::A(address) => RData::A { address }, + dns::RData::AAAA(address) => RData::AAAA { address }, // Still a draft, no iana number yet, I don't to put something that is not currently supported so that's why NULL and not unknown. // TODO: probably need better error here, I don't know what to do about that as this would require to change the From for something else. // (empty data because I'm lazy) - trust_dns_types::RData::ANAME(_) => RData::NULL { + dns::RData::ANAME(_) => RData::NULL { data: String::new() }, - trust_dns_types::RData::CNAME(target) => RData::CNAME { + dns::RData::CNAME(target) => RData::CNAME { target: SerdeName(target) }, - trust_dns_types::RData::CAA(caa) => RData::CAA { + dns::RData::CAA(caa) => RData::CAA { issuer_critical: caa.issuer_critical(), value: format!("{}", CAAValue(caa.value())), property_tag: caa.tag().as_str().to_string(), }, - trust_dns_types::RData::MX(mx) => RData::MX { + dns::RData::MX(mx) => RData::MX { preference: mx.preference(), mail_exchanger: SerdeName(mx.exchange().clone()) }, - trust_dns_types::RData::NULL(null) => RData::NULL { + dns::RData::NULL(null) => RData::NULL { data: base64::encode(null.anything().map(|data| data.to_vec()).unwrap_or_default()) }, - trust_dns_types::RData::NS(target) => RData::NS { + dns::RData::NS(target) => RData::NS { target: SerdeName(target) }, - trust_dns_types::RData::PTR(target) => RData::PTR { + dns::RData::PTR(target) => RData::PTR { target: SerdeName(target) }, - trust_dns_types::RData::SOA(soa) => RData::SOA { + dns::RData::SOA(soa) => RData::SOA { master_server_name: SerdeName(soa.mname().clone()), maintainer_name: SerdeName(soa.rname().clone()), refresh: soa.refresh(), @@ -140,21 +140,21 @@ impl From for RData { minimum: soa.minimum(), serial: soa.serial() }, - trust_dns_types::RData::SRV(srv) => RData::SRV { + dns::RData::SRV(srv) => RData::SRV { server: SerdeName(srv.target().clone()), port: srv.port(), priority: srv.priority(), weight: srv.weight(), }, - trust_dns_types::RData::SSHFP(sshfp) => RData::SSHFP { + dns::RData::SSHFP(sshfp) => RData::SSHFP { algorithm: sshfp.algorithm().into(), digest_type: sshfp.fingerprint_type().into(), - fingerprint: trust_dns_types::sshfp::HEX.encode(sshfp.fingerprint()), + fingerprint: dns::sshfp::HEX.encode(sshfp.fingerprint()), }, //TODO: This might alter data if not utf8 compatible, probably need to be replaced //TODO: check whether concatenating txt data is harmful or not - trust_dns_types::RData::TXT(txt) => RData::TXT { text: format!("{}", txt) }, - trust_dns_types::RData::DNSSEC(data) => RData::DNSSEC(data), + dns::RData::TXT(txt) => RData::TXT { text: format!("{}", txt) }, + dns::RData::DNSSEC(data) => RData::DNSSEC(data), rdata => { let code = rdata.to_record_type().into(); let mut data = Vec::new(); @@ -171,48 +171,48 @@ impl From for RData { } } -impl TryFrom for trust_dns_types::RData { +impl TryFrom for dns::RData { type Error = ProtoError; fn try_from(rdata: RData) -> Result { Ok(match rdata { - RData::A { address } => trust_dns_types::RData::A(address), - RData::AAAA { address } => trust_dns_types::RData::AAAA(address), + RData::A { address } => dns::RData::A(address), + RData::AAAA { address } => dns::RData::AAAA(address), // TODO: Round trip test all types below (currently not tested...) RData::CAA { issuer_critical, value, property_tag } => { - let property = trust_dns_types::caa::Property::from(property_tag); + let property = dns::caa::Property::from(property_tag); let caa_value = { // TODO: duplicate of trust_dns_client::serialize::txt::rdata_parser::caa::parse // because caa::read_value is private match property { - trust_dns_types::caa::Property::Issue | trust_dns_types::caa::Property::IssueWild => { - let value = trust_dns_types::caa::read_issuer(value.as_bytes())?; - trust_dns_types::caa::Value::Issuer(value.0, value.1) + dns::caa::Property::Issue | dns::caa::Property::IssueWild => { + let value = dns::caa::read_issuer(value.as_bytes())?; + dns::caa::Value::Issuer(value.0, value.1) } - trust_dns_types::caa::Property::Iodef => { - let url = trust_dns_types::caa::read_iodef(value.as_bytes())?; - trust_dns_types::caa::Value::Url(url) + dns::caa::Property::Iodef => { + let url = dns::caa::read_iodef(value.as_bytes())?; + dns::caa::Value::Url(url) } - trust_dns_types::caa::Property::Unknown(_) => trust_dns_types::caa::Value::Unknown(value.as_bytes().to_vec()), + dns::caa::Property::Unknown(_) => dns::caa::Value::Unknown(value.as_bytes().to_vec()), } }; - trust_dns_types::RData::CAA(trust_dns_types::caa::CAA { + dns::RData::CAA(dns::caa::CAA { issuer_critical, tag: property, value: caa_value, }) }, - RData::CNAME { target } => trust_dns_types::RData::CNAME(target.into_inner()), - RData::MX { preference, mail_exchanger } => trust_dns_types::RData::MX( - trust_dns_types::mx::MX::new(preference, mail_exchanger.into_inner()) + RData::CNAME { target } => dns::RData::CNAME(target.into_inner()), + RData::MX { preference, mail_exchanger } => dns::RData::MX( + dns::mx::MX::new(preference, mail_exchanger.into_inner()) ), - RData::NULL { data } => trust_dns_types::RData::NULL( - trust_dns_types::null::NULL::with( + RData::NULL { data } => dns::RData::NULL( + dns::null::NULL::with( base64::decode(data).map_err(|e| ProtoError::from(format!("{}", e)))? ) ), - RData::NS { target } => trust_dns_types::RData::NS(target.into_inner()), - RData::PTR { target } => trust_dns_types::RData::PTR(target.into_inner()), + RData::NS { target } => dns::RData::NS(target.into_inner()), + RData::PTR { target } => dns::RData::PTR(target.into_inner()), RData::SOA { master_server_name, maintainer_name, @@ -221,8 +221,8 @@ impl TryFrom for trust_dns_types::RData { expire, minimum, serial - } => trust_dns_types::RData::SOA( - trust_dns_types::soa::SOA::new( + } => dns::RData::SOA( + dns::soa::SOA::new( master_server_name.into_inner(), maintainer_name.into_inner(), serial, @@ -232,18 +232,18 @@ impl TryFrom for trust_dns_types::RData { minimum, ) ), - RData::SRV { server, port, priority, weight } => trust_dns_types::RData::SRV( - trust_dns_types::srv::SRV::new(priority, weight, port, server.into_inner()) + RData::SRV { server, port, priority, weight } => dns::RData::SRV( + dns::srv::SRV::new(priority, weight, port, server.into_inner()) ), - RData::SSHFP { algorithm, digest_type, fingerprint } => trust_dns_types::RData::SSHFP( - trust_dns_types::sshfp::SSHFP::new( + RData::SSHFP { algorithm, digest_type, fingerprint } => dns::RData::SSHFP( + dns::sshfp::SSHFP::new( // NOTE: This allows unassigned algorithms - trust_dns_types::sshfp::Algorithm::from(algorithm), - trust_dns_types::sshfp::FingerprintType::from(digest_type), - trust_dns_types::sshfp::HEX.decode(fingerprint.as_bytes()).map_err(|e| ProtoError::from(format!("{}", e)))? + dns::sshfp::Algorithm::from(algorithm), + dns::sshfp::FingerprintType::from(digest_type), + dns::sshfp::HEX.decode(fingerprint.as_bytes()).map_err(|e| ProtoError::from(format!("{}", e)))? ) ), - RData::TXT { text } => trust_dns_types::RData::TXT(trust_dns_types::txt::TXT::new(vec![text])), + RData::TXT { text } => dns::RData::TXT(dns::txt::TXT::new(vec![text])), // TODO: Error out for DNSSEC? Prefer downstream checks? RData::DNSSEC(_) => todo!(), // TODO: Disallow unknown? (could be used to bypass unsopported types?) Prefer downstream checks? @@ -252,7 +252,7 @@ impl TryFrom for trust_dns_types::RData { } } -struct CAAValue<'a>(&'a trust_dns_types::caa::Value); +struct CAAValue<'a>(&'a dns::caa::Value); // trust_dns Display implementation panics if no parameters // Implementation based on caa::emit_value @@ -260,7 +260,7 @@ struct CAAValue<'a>(&'a trust_dns_types::caa::Value); impl<'a> fmt::Display for CAAValue<'a> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { match self.0 { - trust_dns_types::caa::Value::Issuer(name, parameters) => { + dns::caa::Value::Issuer(name, parameters) => { if let Some(name) = name { write!(f, "{}", name)?; @@ -274,8 +274,8 @@ impl<'a> fmt::Display for CAAValue<'a> { write!(f, "; {}", value)?; } } - trust_dns_types::caa::Value::Url(url) => write!(f, "{}", url)?, - trust_dns_types::caa::Value::Unknown(v) => write!(f, "{:?}", v)?, + dns::caa::Value::Url(url) => write!(f, "{}", url)?, + dns::caa::Value::Unknown(v) => write!(f, "{:?}", v)?, } Ok(()) } diff --git a/src/dns/record.rs b/src/models/record.rs similarity index 74% rename from src/dns/record.rs rename to src/models/record.rs index dc1debb..3c463cd 100644 --- a/src/dns/record.rs +++ b/src/models/record.rs @@ -2,7 +2,7 @@ use std::convert::{TryFrom, TryInto}; use serde::{Deserialize, Serialize}; use trust_dns_proto::error::ProtoError; -use super::trust_dns_types; +use crate::dns; use super::name::SerdeName; use super::class::DNSClass; use super::rdata::RData; @@ -21,8 +21,8 @@ pub struct Record { pub rdata: RData, } -impl From for Record { - fn from(record: trust_dns_types::Record) -> Record { +impl From for Record { + fn from(record: dns::Record) -> Record { Record { name: SerdeName(record.name().clone()), dns_class: record.dns_class().into(), @@ -32,11 +32,11 @@ impl From for Record { } } -impl TryFrom for trust_dns_types::Record { +impl TryFrom for dns::Record { type Error = ProtoError; fn try_from(record: Record) -> Result { - let mut trust_dns_record = trust_dns_types::Record::from_rdata(record.name.into_inner(), record.ttl, record.rdata.try_into()?); + let mut trust_dns_record = dns::Record::from_rdata(record.name.into_inner(), record.ttl, record.rdata.try_into()?); trust_dns_record.set_dns_class(record.dns_class.into()); Ok(trust_dns_record) } diff --git a/src/models/zone.rs b/src/models/zone.rs index 47e62d1..afca690 100644 --- a/src/models/zone.rs +++ b/src/models/zone.rs @@ -6,9 +6,9 @@ use diesel::result::Error as DieselError; use serde::{Serialize, Deserialize}; use crate::schema::*; -use crate::dns::name::AbsoluteName; -use crate::models::user::UserZone; -use crate::models::errors::UserError; +use super::name::AbsoluteName; +use super::user::UserZone; +use super::errors::UserError; #[derive(Debug, Serialize, Queryable, Identifiable, Insertable)] diff --git a/src/routes/users.rs b/src/routes/users.rs index 524d801..bf0ba61 100644 --- a/src/routes/users.rs +++ b/src/routes/users.rs @@ -4,34 +4,32 @@ use rocket::http::Status; use crate::config::Config; use crate::DbConn; -use crate::models::errors::{ErrorResponse, make_500}; -use crate::models::user::{LocalUser, CreateUserRequest}; -use crate::models::auth::{AuthClaims, AuthTokenRequest, AuthTokenResponse}; +use crate::models; #[post("/users/me/token", data = "")] pub async fn create_auth_token( conn: DbConn, config: State<'_, Config>, - auth_request: Json -) -> Result, ErrorResponse> { + auth_request: Json +) -> Result, models::ErrorResponse> { let user_info = conn.run(move |c| { - LocalUser::get_user_by_creds(c, &auth_request.username, &auth_request.password) + models::LocalUser::get_user_by_creds(c, &auth_request.username, &auth_request.password) }).await?; - let token = AuthClaims::new(&user_info, config.web_app.token_duration) + let token = models::AuthClaims::new(&user_info, config.web_app.token_duration) .encode(&config.web_app.secret) - .map_err(make_500)?; + .map_err(models::make_500)?; - Ok(Json(AuthTokenResponse { token })) + Ok(Json(models::AuthTokenResponse { token })) } #[post("/users", data = "")] -pub async fn create_user<'r>(conn: DbConn, user_request: Json) -> Result, ErrorResponse> { +pub async fn create_user<'r>(conn: DbConn, user_request: Json) -> Result, models::ErrorResponse> { // TODO: Check current user if any to check if user has permission to create users (with or without role) conn.run(|c| { - LocalUser::create_user(&c, user_request.into_inner()) + models::LocalUser::create_user(&c, user_request.into_inner()) }).await?; Response::build() diff --git a/src/routes/zones.rs b/src/routes/zones.rs index 25bf124..b884661 100644 --- a/src/routes/zones.rs +++ b/src/routes/zones.rs @@ -9,46 +9,45 @@ use trust_dns_client::client::ClientHandle; use trust_dns_client::op::ResponseCode; use trust_dns_client::rr::{DNSClass, RecordType}; -use crate::{dns::{self, trust_dns_types}, DbConn}; -use crate::models::errors::{ErrorResponse, make_500}; -use crate::models::user::{LocalUser, UserInfo}; -use crate::models::zone::{Zone, AddZoneMemberRequest, CreateZoneRequest}; +use crate::DbConn; +use crate::dns; use crate::dns::message::DnsMessage; use crate::dns::message::MessageError; +use crate::models; #[get("/zones//records")] pub async fn get_zone_records( mut client: dns::client::DnsClient, conn: DbConn, - user_info: Result, - zone: dns::name::AbsoluteName -) -> Result>, ErrorResponse> { + user_info: Result, + zone: models::AbsoluteName +) -> Result>, models::ErrorResponse> { let user_info = user_info?; let zone_name = zone.to_string(); conn.run(move |c| { if user_info.is_admin() { - Zone::get_by_name(c, &zone_name) + models::Zone::get_by_name(c, &zone_name) } else { user_info.get_zone(c, &zone_name) } }).await?; let records: Vec<_> = match client.get_records(zone.clone(), DNSClass::IN).await { - Ok(records) => records.into_iter().map(dns::record::Record::from).collect(), + Ok(records) => records.into_iter().map(models::Record::from).collect(), Err(MessageError::ResponceNotOk(code)) => { println!("Querrying AXFR of zone {} failed with code {}", *zone, code); - return ErrorResponse::new( + return models::ErrorResponse::new( Status::NotFound, "Zone could not be found".into() ).with_details(json!({ "zone_name": zone.to_utf8() })).err(); }, - Err(err) => { return make_500(err).err(); }, + Err(err) => { return models::make_500(err).err(); }, }; Ok(Json(records)) @@ -58,17 +57,17 @@ pub async fn get_zone_records( pub async fn create_zone_records( mut client: dns::client::DnsClient, conn: DbConn, - user_info: Result, - zone: dns::name::AbsoluteName, - new_records: Json> -) -> Result, ErrorResponse> { + user_info: Result, + zone: models::AbsoluteName, + new_records: Json> +) -> Result, models::ErrorResponse> { let user_info = user_info?; let zone_name = zone.to_utf8(); conn.run(move |c| { if user_info.is_admin() { - Zone::get_by_name(c, &zone_name) + models::Zone::get_by_name(c, &zone_name) } else { user_info.get_zone(c, &zone_name) } @@ -76,7 +75,7 @@ pub async fn create_zone_records( // TODO: What about relative names (also in cnames and stuff) let mut bad_records = Vec::new(); - let mut records: Vec = Vec::new(); + let mut records: Vec = Vec::new(); for record in new_records.into_inner().into_iter() { let this_record = record.clone(); @@ -88,7 +87,7 @@ pub async fn create_zone_records( } if !bad_records.is_empty() { - return ErrorResponse::new( + return models::ErrorResponse::new( Status::BadRequest, "Record list contains records that could not been parsed into DNS records".into() ).with_details( @@ -100,27 +99,27 @@ pub async fn create_zone_records( } let response = match client.add_records(zone.clone(), DNSClass::IN, records) { - Ok(query) => query.await.map_err(make_500)?, + Ok(query) => query.await.map_err(models::make_500)?, Err(MessageError::RecordNotInZone { zone, class, mismatched_class, mismatched_zone}) => { - return ErrorResponse::new( + return models::ErrorResponse::new( Status::BadRequest, "Record list contains records that do not belong to the zone".into() ).with_details( json!({ "zone_name": zone.to_utf8(), - "class": dns::class::DNSClass::from(class), - "mismatched_class": mismatched_class.into_iter().map(|r| r.clone().into()).collect::>(), - "mismatched_zone": mismatched_zone.into_iter().map(|r| r.clone().into()).collect::>(), + "class": models::DNSClass::from(class), + "mismatched_class": mismatched_class.into_iter().map(|r| r.clone().into()).collect::>(), + "mismatched_zone": mismatched_zone.into_iter().map(|r| r.clone().into()).collect::>(), }) ).err(); }, - Err(e) => return make_500(e).err() + Err(e) => return models::make_500(e).err() }; // TODO: better error handling if response.response_code() != ResponseCode::NoError { println!("Update of zone {} failed with code {}", *zone, response.response_code()); - return ErrorResponse::new( + return models::ErrorResponse::new( Status::NotFound, "Update of zone failed".into() ).with_details(json!({ @@ -134,13 +133,13 @@ pub async fn create_zone_records( #[get("/zones")] pub async fn get_zones( conn: DbConn, - user_info: Result, -) -> Result>, ErrorResponse> { + user_info: Result, +) -> Result>, models::ErrorResponse> { let user_info = user_info?; let zones = conn.run(move |c| { if user_info.is_admin() { - Zone::get_all(c) + models::Zone::get_all(c) } else { user_info.get_zones(c) } @@ -153,27 +152,27 @@ pub async fn get_zones( pub async fn create_zone( conn: DbConn, mut client: dns::client::DnsClient, - user_info: Result, - zone_request: Json, -) -> Result, ErrorResponse> { + user_info: Result, + zone_request: Json, +) -> Result, models::ErrorResponse> { user_info?.check_admin()?; // Check if the zone exists in the DNS server let response = { let query = client.query(zone_request.name.clone(), DNSClass::IN, RecordType::SOA); - query.await.map_err(make_500)? + query.await.map_err(models::make_500)? }; if response.response_code() != ResponseCode::NoError { println!("Querrying SOA of zone {} failed with code {}", *zone_request.name, response.response_code()); - return ErrorResponse::new( + return models::ErrorResponse::new( Status::NotFound, format!("Zone {} could not be found", *zone_request.name) ).err() } let zone = conn.run(move |c| { - Zone::create_zone(c, zone_request.into_inner()) + models::Zone::create_zone(c, zone_request.into_inner()) }).await?; Ok(Json(zone)) @@ -183,21 +182,21 @@ pub async fn create_zone( #[post("/zones//members", data = "")] pub async fn add_member_to_zone<'r>( conn: DbConn, - zone: dns::name::AbsoluteName, - user_info: Result, - zone_member_request: Json -) -> Result, ErrorResponse> { + zone: models::AbsoluteName, + user_info: Result, + zone_member_request: Json +) -> Result, models::ErrorResponse> { let user_info = user_info?; let zone_name = zone.to_utf8(); conn.run(move |c| { let zone = if user_info.is_admin() { - Zone::get_by_name(c, &zone_name) + models::Zone::get_by_name(c, &zone_name) } else { user_info.get_zone(c, &zone_name) }?; - let new_member = LocalUser::get_user_by_uuid(c, &zone_member_request.id)?; + let new_member = models::LocalUser::get_user_by_uuid(c, &zone_member_request.id)?; zone.add_member(&c, &new_member) }).await?; From befed6a38b141b8075d892ef6c5c4ba63416755b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Berthaud-M=C3=BCller?= Date: Fri, 4 Mar 2022 17:18:26 +0100 Subject: [PATCH 10/19] update gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index a631336..48f07a6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ config.toml db.sqlite +__pycache__ +/env \ No newline at end of file From 247f72871e4dc3e78f1c2cc27d5d7aab262735c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Berthaud-M=C3=BCller?= Date: Fri, 4 Mar 2022 17:51:07 +0100 Subject: [PATCH 11/19] create trait for dns server interactions --- src/dns/api.rs | 24 ++++++++++++++++ src/dns/client.rs | 4 +-- src/dns/{message.rs => dns_api.rs} | 34 ++++++++++++++++++----- src/dns/mod.rs | 8 ++++-- src/routes/zones.rs | 44 ++++++++++++++++-------------- 5 files changed, 82 insertions(+), 32 deletions(-) create mode 100644 src/dns/api.rs rename src/dns/{message.rs => dns_api.rs} (73%) diff --git a/src/dns/api.rs b/src/dns/api.rs new file mode 100644 index 0000000..83b2038 --- /dev/null +++ b/src/dns/api.rs @@ -0,0 +1,24 @@ +use crate::dns; + +// TODO: Use model types instead of dns types as input / output and only convert internaly? + +// Zone content api +// E.g.: DNS update + axfr, zone file read + write +#[async_trait] +pub trait RecordApi { + type Error; + + async fn get_records(&mut self, zone: dns::Name, class: dns::DNSClass) -> Result, Self::Error>; + async fn add_records(&mut self, zone: dns::Name, class: dns::DNSClass, new_records: Vec) -> Result<(), Self::Error>; + // update_records + // delete_records +} + +// Zone management api, todo +// E.g.: Manage catalog zone, dynamically generate knot / bind / nsd config... +pub trait ZoneApi { + // get_zones + // add_zone + // delete_zone + // exists +} \ No newline at end of file diff --git a/src/dns/client.rs b/src/dns/client.rs index da942fe..dfcd761 100644 --- a/src/dns/client.rs +++ b/src/dns/client.rs @@ -8,7 +8,7 @@ use trust_dns_proto::error::ProtoError; use trust_dns_proto::iocompat::AsyncIoTokioAsStd; use crate::config::Config; -use super::message::DnsMessage; + pub struct DnsClient(AsyncClient); @@ -27,8 +27,6 @@ impl DerefMut for DnsClient { } } -impl DnsMessage for AsyncClient {} - #[rocket::async_trait] impl<'r> FromRequest<'r> for DnsClient { diff --git a/src/dns/message.rs b/src/dns/dns_api.rs similarity index 73% rename from src/dns/message.rs rename to src/dns/dns_api.rs index 5ff620f..38a8557 100644 --- a/src/dns/message.rs +++ b/src/dns/dns_api.rs @@ -2,11 +2,11 @@ use trust_dns_proto::DnsHandle; use trust_dns_client::rr::{DNSClass, RecordType}; use trust_dns_client::op::{UpdateMessage, OpCode, MessageType, Message, Query, ResponseCode}; use trust_dns_client::error::ClientError; -use trust_dns_proto::error::ProtoError; use trust_dns_client::proto::xfer::{DnsRequestOptions}; use super::{Name, Record, RData}; -use super::client::{ClientResponse}; +use super::client::{ClientResponse, DnsClient}; +use super::api::RecordApi; #[derive(Debug)] @@ -21,15 +21,29 @@ pub enum MessageError { ResponceNotOk(ResponseCode) } +pub struct DnsApiClient { + client: DnsClient +} + +impl DnsApiClient { + pub fn new(client: DnsClient) -> Self { + DnsApiClient { + client + } + } +} + #[async_trait] -pub trait DnsMessage: DnsHandle + Send { - async fn get_records(&mut self, zone: Name, class: DNSClass) -> Result, MessageError> +impl RecordApi for DnsApiClient { + type Error = MessageError; + + async fn get_records(&mut self, zone: Name, class: DNSClass) -> Result, Self::Error> { let response = { let mut query = Query::query(zone, RecordType::AXFR); query.set_query_class(class); - ClientResponse(self.lookup(query, DnsRequestOptions::default())).await.map_err(|e| MessageError::ClientError(e))? + ClientResponse(self.client.lookup(query, DnsRequestOptions::default())).await.map_err(|e| MessageError::ClientError(e))? }; if response.response_code() != ResponseCode::NoError { @@ -46,7 +60,7 @@ pub trait DnsMessage: DnsHandle + Send { Ok(records) } - fn add_records(&mut self, zone: Name, class: DNSClass, new_records: Vec) -> Result::Response>, MessageError> + async fn add_records(&mut self, zone: Name, class: DNSClass, new_records: Vec) -> Result<(), Self::Error> { let mut mismatched_class = Vec::new(); let mut mismatched_zone = Vec::new(); @@ -90,6 +104,12 @@ pub trait DnsMessage: DnsHandle + Send { edns.set_version(0); } - return Ok(ClientResponse(self.send(message))); + let response = ClientResponse(self.client.send(message)).await.map_err(|e| MessageError::ClientError(e))?; + + if response.response_code() != ResponseCode::NoError { + return Err(MessageError::ResponceNotOk(response.response_code())); + } + + Ok(()) } } \ No newline at end of file diff --git a/src/dns/mod.rs b/src/dns/mod.rs index 2aa75ab..fb43308 100644 --- a/src/dns/mod.rs +++ b/src/dns/mod.rs @@ -1,5 +1,6 @@ pub mod client; -pub mod message; +pub mod dns_api; +pub mod api; // Reexport trust dns types for convenience pub use trust_dns_client::rr::rdata::{ @@ -8,4 +9,7 @@ pub use trust_dns_client::rr::rdata::{ pub use trust_dns_client::rr::{ RData, DNSClass, Record }; -pub use trust_dns_proto::rr::Name; \ No newline at end of file +pub use trust_dns_proto::rr::Name; + +// Reexport module types +pub use api::{RecordApi, ZoneApi}; \ No newline at end of file diff --git a/src/routes/zones.rs b/src/routes/zones.rs index b884661..1276b6d 100644 --- a/src/routes/zones.rs +++ b/src/routes/zones.rs @@ -11,14 +11,15 @@ use trust_dns_client::rr::{DNSClass, RecordType}; use crate::DbConn; use crate::dns; -use crate::dns::message::DnsMessage; -use crate::dns::message::MessageError; +use crate::dns::api::RecordApi; +use crate::dns::dns_api::DnsApiClient; +use crate::dns::dns_api::MessageError; use crate::models; #[get("/zones//records")] pub async fn get_zone_records( - mut client: dns::client::DnsClient, + client: dns::client::DnsClient, conn: DbConn, user_info: Result, zone: models::AbsoluteName @@ -35,7 +36,9 @@ pub async fn get_zone_records( } }).await?; - let records: Vec<_> = match client.get_records(zone.clone(), DNSClass::IN).await { + let mut dns_api = DnsApiClient::new(client); + + let records: Vec<_> = match dns_api.get_records(zone.clone(), DNSClass::IN).await { Ok(records) => records.into_iter().map(models::Record::from).collect(), Err(MessageError::ResponceNotOk(code)) => { @@ -55,7 +58,7 @@ pub async fn get_zone_records( #[post("/zones//records", data = "")] pub async fn create_zone_records( - mut client: dns::client::DnsClient, + client: dns::client::DnsClient, conn: DbConn, user_info: Result, zone: models::AbsoluteName, @@ -98,8 +101,13 @@ pub async fn create_zone_records( ).err(); } - let response = match client.add_records(zone.clone(), DNSClass::IN, records) { - Ok(query) => query.await.map_err(models::make_500)?, + let mut dns_api = DnsApiClient::new(client); + + match dns_api.add_records(zone.clone(), DNSClass::IN, records).await { + Ok(_) => { + return Ok(Json(())); + //query.await.map_err(models::make_500)?; + } Err(MessageError::RecordNotInZone { zone, class, mismatched_class, mismatched_zone}) => { return models::ErrorResponse::new( Status::BadRequest, @@ -113,21 +121,17 @@ pub async fn create_zone_records( }) ).err(); }, + Err(MessageError::ResponceNotOk(code)) => { + println!("Update of zone {} failed with code {}", *zone, code); + return models::ErrorResponse::new( + Status::NotFound, + "Update of zone failed".into() + ).with_details(json!({ + "zone_name": zone.to_utf8() + })).err(); + }, Err(e) => return models::make_500(e).err() }; - - // TODO: better error handling - if response.response_code() != ResponseCode::NoError { - println!("Update of zone {} failed with code {}", *zone, response.response_code()); - return models::ErrorResponse::new( - Status::NotFound, - "Update of zone failed".into() - ).with_details(json!({ - "zone_name": zone.to_utf8() - })).err(); - } - - Ok(Json(())) } #[get("/zones")] From 96f66e1fd7acf66f9597e99bc14fcf88768c8bf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Berthaud-M=C3=BCller?= Date: Fri, 4 Mar 2022 18:20:18 +0100 Subject: [PATCH 12/19] unified dns api error and move zone operations to management api --- src/dns/api.rs | 5 ++- src/dns/dns_api.rs | 54 ++++++++++++++++++++++++------ src/dns/mod.rs | 3 +- src/models/errors.rs | 34 ++++++++++++++++++- src/routes/zones.rs | 78 +++++++------------------------------------- 5 files changed, 94 insertions(+), 80 deletions(-) diff --git a/src/dns/api.rs b/src/dns/api.rs index 83b2038..bf5cf1d 100644 --- a/src/dns/api.rs +++ b/src/dns/api.rs @@ -16,9 +16,12 @@ pub trait RecordApi { // Zone management api, todo // E.g.: Manage catalog zone, dynamically generate knot / bind / nsd config... +#[async_trait] pub trait ZoneApi { + type Error; // get_zones // add_zone // delete_zone - // exists + // zone_exists + async fn zone_exists(&mut self, zone: dns::Name, class: dns::DNSClass) -> Result<(), Self::Error>; } \ No newline at end of file diff --git a/src/dns/dns_api.rs b/src/dns/dns_api.rs index 38a8557..2e6dd1d 100644 --- a/src/dns/dns_api.rs +++ b/src/dns/dns_api.rs @@ -6,11 +6,11 @@ use trust_dns_client::proto::xfer::{DnsRequestOptions}; use super::{Name, Record, RData}; use super::client::{ClientResponse, DnsClient}; -use super::api::RecordApi; +use super::api::{RecordApi, ZoneApi}; #[derive(Debug)] -pub enum MessageError { +pub enum DnsApiError { RecordNotInZone { zone: Name, class: DNSClass, @@ -18,7 +18,10 @@ pub enum MessageError { mismatched_zone: Vec, }, ClientError(ClientError), - ResponceNotOk(ResponseCode) + ResponceNotOk { + code: ResponseCode, + zone: Name, + }, } pub struct DnsApiClient { @@ -36,18 +39,21 @@ impl DnsApiClient { #[async_trait] impl RecordApi for DnsApiClient { - type Error = MessageError; + type Error = DnsApiError; async fn get_records(&mut self, zone: Name, class: DNSClass) -> Result, Self::Error> { let response = { - let mut query = Query::query(zone, RecordType::AXFR); + let mut query = Query::query(zone.clone(), RecordType::AXFR); query.set_query_class(class); - ClientResponse(self.client.lookup(query, DnsRequestOptions::default())).await.map_err(|e| MessageError::ClientError(e))? + ClientResponse(self.client.lookup(query, DnsRequestOptions::default())).await.map_err(|e| DnsApiError::ClientError(e))? }; if response.response_code() != ResponseCode::NoError { - return Err(MessageError::ResponceNotOk(response.response_code())); + return Err(DnsApiError::ResponceNotOk { + code: response.response_code(), + zone: zone, + }); } let answers = response.answers(); @@ -75,7 +81,7 @@ impl RecordApi for DnsApiClient { } if mismatched_class.len() > 0 || mismatched_zone.len() > 0 { - return Err(MessageError::RecordNotInZone { + return Err(DnsApiError::RecordNotInZone { zone, class, mismatched_zone, @@ -104,12 +110,40 @@ impl RecordApi for DnsApiClient { edns.set_version(0); } - let response = ClientResponse(self.client.send(message)).await.map_err(|e| MessageError::ClientError(e))?; + let response = ClientResponse(self.client.send(message)).await.map_err(|e| DnsApiError::ClientError(e))?; if response.response_code() != ResponseCode::NoError { - return Err(MessageError::ResponceNotOk(response.response_code())); + return Err(DnsApiError::ResponceNotOk { + code: response.response_code(), + zone: zone, + }); } Ok(()) } +} + + +#[async_trait] +impl ZoneApi for DnsApiClient { + type Error = DnsApiError; + + async fn zone_exists(&mut self, zone: Name, class: DNSClass) -> Result<(), Self::Error> + { + let response = { + let mut query = Query::query(zone.clone(), RecordType::SOA); + query.set_query_class(class); + ClientResponse(self.client.lookup(query, DnsRequestOptions::default())).await.map_err(|e| DnsApiError::ClientError(e))? + }; + + if response.response_code() != ResponseCode::NoError { + return Err(DnsApiError::ResponceNotOk { + code: response.response_code(), + zone: zone, + }); + } + + Ok(()) + } + } \ No newline at end of file diff --git a/src/dns/mod.rs b/src/dns/mod.rs index fb43308..73158c4 100644 --- a/src/dns/mod.rs +++ b/src/dns/mod.rs @@ -12,4 +12,5 @@ pub use trust_dns_client::rr::{ pub use trust_dns_proto::rr::Name; // Reexport module types -pub use api::{RecordApi, ZoneApi}; \ No newline at end of file +pub use api::{RecordApi, ZoneApi}; +pub use dns_api::DnsApiClient; \ No newline at end of file diff --git a/src/models/errors.rs b/src/models/errors.rs index c7edd76..bfbcd96 100644 --- a/src/models/errors.rs +++ b/src/models/errors.rs @@ -6,7 +6,8 @@ use rocket_contrib::json::Json; use serde_json::Value; use djangohashers::{HasherError}; use diesel::result::Error as DieselError; - +use crate::dns::dns_api::DnsApiError; +use crate::models; #[derive(Debug)] pub enum UserError { @@ -98,6 +99,37 @@ impl From for ErrorResponse { } } +impl From for ErrorResponse { + fn from(e: DnsApiError) -> Self { + match e { + DnsApiError::RecordNotInZone { zone, class, mismatched_class, mismatched_zone} => { + ErrorResponse::new( + Status::BadRequest, + "Record list contains records that do not belong to the zone".into() + ).with_details( + json!({ + "zone_name": zone.to_utf8(), + "class": models::DNSClass::from(class), + "mismatched_class": mismatched_class.into_iter().map(|r| r.clone().into()).collect::>(), + "mismatched_zone": mismatched_zone.into_iter().map(|r| r.clone().into()).collect::>(), + }) + ) + }, + DnsApiError::ResponceNotOk { code, zone } => { + println!("Query for zone {} failed with code {}", zone, code); + + ErrorResponse::new( + Status::NotFound, + "Zone could not be found".into() + ).with_details(json!({ + "zone_name": zone.to_utf8() + })) + }, + DnsApiError::ClientError(e) => make_500(e) + } + } +} + impl From for Outcome { fn from(e: ErrorResponse) -> Self { diff --git a/src/routes/zones.rs b/src/routes/zones.rs index 1276b6d..91032dd 100644 --- a/src/routes/zones.rs +++ b/src/routes/zones.rs @@ -5,16 +5,10 @@ use rocket::http::Status; use rocket_contrib::json::Json; -use trust_dns_client::client::ClientHandle; -use trust_dns_client::op::ResponseCode; -use trust_dns_client::rr::{DNSClass, RecordType}; - use crate::DbConn; -use crate::dns; -use crate::dns::api::RecordApi; -use crate::dns::dns_api::DnsApiClient; -use crate::dns::dns_api::MessageError; use crate::models; +use crate::dns; +use crate::dns::{RecordApi, ZoneApi}; #[get("/zones//records")] @@ -36,22 +30,10 @@ pub async fn get_zone_records( } }).await?; - let mut dns_api = DnsApiClient::new(client); + let mut dns_api = dns::DnsApiClient::new(client); - let records: Vec<_> = match dns_api.get_records(zone.clone(), DNSClass::IN).await { - Ok(records) => records.into_iter().map(models::Record::from).collect(), - - Err(MessageError::ResponceNotOk(code)) => { - println!("Querrying AXFR of zone {} failed with code {}", *zone, code); - return models::ErrorResponse::new( - Status::NotFound, - "Zone could not be found".into() - ).with_details(json!({ - "zone_name": zone.to_utf8() - })).err(); - }, - Err(err) => { return models::make_500(err).err(); }, - }; + let dns_records = dns_api.get_records(zone.clone(), dns::DNSClass::IN).await?; + let records: Vec<_> = dns_records.into_iter().map(models::Record::from).collect(); Ok(Json(records)) } @@ -101,37 +83,10 @@ pub async fn create_zone_records( ).err(); } - let mut dns_api = DnsApiClient::new(client); + let mut dns_api = dns::DnsApiClient::new(client); + dns_api.add_records(zone.clone(), dns::DNSClass::IN, records).await?; - match dns_api.add_records(zone.clone(), DNSClass::IN, records).await { - Ok(_) => { - return Ok(Json(())); - //query.await.map_err(models::make_500)?; - } - Err(MessageError::RecordNotInZone { zone, class, mismatched_class, mismatched_zone}) => { - return models::ErrorResponse::new( - Status::BadRequest, - "Record list contains records that do not belong to the zone".into() - ).with_details( - json!({ - "zone_name": zone.to_utf8(), - "class": models::DNSClass::from(class), - "mismatched_class": mismatched_class.into_iter().map(|r| r.clone().into()).collect::>(), - "mismatched_zone": mismatched_zone.into_iter().map(|r| r.clone().into()).collect::>(), - }) - ).err(); - }, - Err(MessageError::ResponceNotOk(code)) => { - println!("Update of zone {} failed with code {}", *zone, code); - return models::ErrorResponse::new( - Status::NotFound, - "Update of zone failed".into() - ).with_details(json!({ - "zone_name": zone.to_utf8() - })).err(); - }, - Err(e) => return models::make_500(e).err() - }; + return Ok(Json(())); } #[get("/zones")] @@ -155,25 +110,14 @@ pub async fn get_zones( #[post("/zones", data = "")] pub async fn create_zone( conn: DbConn, - mut client: dns::client::DnsClient, + client: dns::client::DnsClient, user_info: Result, zone_request: Json, ) -> Result, models::ErrorResponse> { user_info?.check_admin()?; - // Check if the zone exists in the DNS server - let response = { - let query = client.query(zone_request.name.clone(), DNSClass::IN, RecordType::SOA); - query.await.map_err(models::make_500)? - }; - - if response.response_code() != ResponseCode::NoError { - println!("Querrying SOA of zone {} failed with code {}", *zone_request.name, response.response_code()); - return models::ErrorResponse::new( - Status::NotFound, - format!("Zone {} could not be found", *zone_request.name) - ).err() - } + let mut dns_api = dns::DnsApiClient::new(client); + dns_api.zone_exists(zone_request.name.clone(), dns::DNSClass::IN).await?; let zone = conn.run(move |c| { models::Zone::create_zone(c, zone_request.into_inner()) From 77cc634257194d10b56793ef460d5591e9946d00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Berthaud-M=C3=BCller?= Date: Fri, 4 Mar 2022 19:43:19 +0100 Subject: [PATCH 13/19] move record list conversion to models module --- src/models/errors.rs | 14 ++++++++++++++ src/models/mod.rs | 2 +- src/models/name.rs | 6 ++++++ src/models/rdata.rs | 2 +- src/models/record.rs | 37 +++++++++++++++++++++++++++++++++++++ src/routes/zones.rs | 36 ++++++++---------------------------- 6 files changed, 67 insertions(+), 30 deletions(-) diff --git a/src/models/errors.rs b/src/models/errors.rs index bfbcd96..688f113 100644 --- a/src/models/errors.rs +++ b/src/models/errors.rs @@ -130,6 +130,20 @@ impl From for ErrorResponse { } } +impl From for ErrorResponse { + fn from(e: models::RecordListParseError) -> Self { + models::ErrorResponse::new( + Status::BadRequest, + "Record list contains records that could not be parsed into DNS records".into() + ).with_details( + json!({ + "zone_name": e.zone.to_utf8(), + "records": e.bad_records + }) + ) + } +} + impl From for Outcome { fn from(e: ErrorResponse) -> Self { diff --git a/src/models/mod.rs b/src/models/mod.rs index 0b387b9..32b6a4e 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -15,5 +15,5 @@ pub use errors::{UserError, ErrorResponse, make_500}; pub use name::{AbsoluteName, SerdeName}; pub use user::{LocalUser, UserInfo, Role, UserZone, User, CreateUserRequest}; pub use rdata::RData; -pub use record::Record; +pub use record::{Record, RecordList, ParseRecordList, RecordListParseError}; pub use zone::{Zone, AddZoneMemberRequest, CreateZoneRequest}; \ No newline at end of file diff --git a/src/models/name.rs b/src/models/name.rs index 0856f11..11438e9 100644 --- a/src/models/name.rs +++ b/src/models/name.rs @@ -69,4 +69,10 @@ impl Deref for AbsoluteName { fn deref(&self) -> &Self::Target { &self.0.0 } +} + +impl AbsoluteName { + pub fn into_inner(self) -> Name { + self.0.0 + } } \ No newline at end of file diff --git a/src/models/rdata.rs b/src/models/rdata.rs index 64c61be..775bce8 100644 --- a/src/models/rdata.rs +++ b/src/models/rdata.rs @@ -247,7 +247,7 @@ impl TryFrom for dns::RData { // TODO: Error out for DNSSEC? Prefer downstream checks? RData::DNSSEC(_) => todo!(), // TODO: Disallow unknown? (could be used to bypass unsopported types?) Prefer downstream checks? - RData::Unknown { code, data } => todo!(), + RData::Unknown { code: _code, data: _data } => todo!(), }) } } diff --git a/src/models/record.rs b/src/models/record.rs index 3c463cd..091a47d 100644 --- a/src/models/record.rs +++ b/src/models/record.rs @@ -40,4 +40,41 @@ impl TryFrom for dns::Record { trust_dns_record.set_dns_class(record.dns_class.into()); Ok(trust_dns_record) } +} + + +pub type RecordList = Vec; + +pub struct RecordListParseError { + pub bad_records: Vec, + pub zone: dns::Name, +} + +pub trait ParseRecordList { + fn try_into_dns_type(self, zone: dns::Name) -> Result, RecordListParseError>; +} + +impl ParseRecordList for RecordList { + fn try_into_dns_type(self, zone: dns::Name) -> Result, RecordListParseError> { + // TODO: What about relative names (also in cnames and stuff) + let mut bad_records = Vec::new(); + let mut records: Vec = Vec::new(); + + for record in self.into_iter() { + let this_record = record.clone(); + if let Ok(record) = record.try_into() { + records.push(record); + } else { + bad_records.push(this_record.clone()); + } + } + + if !bad_records.is_empty() { + return Err(RecordListParseError { + zone, + bad_records, + }); + } + return Ok(records) + } } \ No newline at end of file diff --git a/src/routes/zones.rs b/src/routes/zones.rs index 91032dd..ca91a21 100644 --- a/src/routes/zones.rs +++ b/src/routes/zones.rs @@ -1,14 +1,13 @@ -use std::convert::TryInto; -use serde_json::json; use rocket::Response; use rocket::http::Status; use rocket_contrib::json::Json; use crate::DbConn; -use crate::models; use crate::dns; +use crate::models; use crate::dns::{RecordApi, ZoneApi}; +use crate::models::{ParseRecordList}; #[get("/zones//records")] @@ -58,33 +57,14 @@ pub async fn create_zone_records( } }).await?; - // TODO: What about relative names (also in cnames and stuff) - let mut bad_records = Vec::new(); - let mut records: Vec = Vec::new(); - - for record in new_records.into_inner().into_iter() { - let this_record = record.clone(); - if let Ok(record) = record.try_into() { - records.push(record); - } else { - bad_records.push(this_record.clone()); - } - } - - if !bad_records.is_empty() { - return models::ErrorResponse::new( - Status::BadRequest, - "Record list contains records that could not been parsed into DNS records".into() - ).with_details( - json!({ - "zone_name": zone.to_utf8(), - "records": bad_records - }) - ).err(); - } let mut dns_api = dns::DnsApiClient::new(client); - dns_api.add_records(zone.clone(), dns::DNSClass::IN, records).await?; + + dns_api.add_records( + zone.clone(), + models::DNSClass::IN.into(), + new_records.into_inner().try_into_dns_type(zone.into_inner())? + ).await?; return Ok(Json(())); } From 3767cc6ea0a072a34496ce268288e0d66cad6e13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Berthaud-M=C3=BCller?= Date: Fri, 4 Mar 2022 21:55:27 +0100 Subject: [PATCH 14/19] add records update --- api.yml | 23 ++++++++++ e2e/zones.py | 39 +++++++++++++++- src/dns/api.rs | 3 +- src/dns/dns_api.rs | 107 +++++++++++++++++++++++++++++-------------- src/dns/mod.rs | 3 +- src/main.rs | 1 + src/models/class.rs | 2 +- src/models/errors.rs | 48 ++++++++++--------- src/models/mod.rs | 2 +- src/models/rdata.rs | 2 +- src/models/record.rs | 62 +++++++++++++++++++++---- src/routes/zones.rs | 61 ++++++++++++++++++------ 12 files changed, 266 insertions(+), 87 deletions(-) diff --git a/api.yml b/api.yml index 5d8e720..13913e4 100644 --- a/api.yml +++ b/api.yml @@ -307,6 +307,17 @@ components: items: $ref: '#/components/schemas/Record' + UpdateRecordsRequest: + type: object + required: + - oldRecords + - newRecords + properties: + oldRecords: + $ref: '#/components/schemas/RecordList' + newRecords: + $ref: '#/components/schemas/RecordList' + paths: '/users': @@ -400,3 +411,15 @@ paths: responses: '200': description: '' + put: + security: + - ApiToken: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateRecordsRequest' + responses: + '200': + description: '' + diff --git a/e2e/zones.py b/e2e/zones.py index 0e0ec3a..2867df9 100644 --- a/e2e/zones.py +++ b/e2e/zones.py @@ -7,7 +7,8 @@ from nomilo_client.models import ( RecordTypeCNAME, RecordTypeNS, RecordTypeTXT, - RecordList + RecordList, + UpdateRecordsRequest, ) import logging @@ -97,3 +98,39 @@ class TestZones(unittest.TestCase): found = True self.assertTrue(found, msg='New record not found in zone records') + + def test_update_records(self): + name = random_name('example.com.') + old_record = RecordTypeTXT( + _class='IN', + ttl=300, + name=name, + text='old value', + type='TXT' + ) + + new_record = RecordTypeTXT( + _class='IN', + ttl=300, + name=name, + text='new value', + type='TXT' + ) + + self.api.zones_zone_records_post(zone='example.com.', record_list=RecordList(value=[old_record])) + + update_records_request = UpdateRecordsRequest( + old_records=RecordList([old_record]), + new_records=RecordList([new_record]), + ) + + self.api.zones_zone_records_put(zone='example.com.', update_records_request=update_records_request) + + records = self.api.zones_zone_records_get(zone='example.com.') + found = False + for record in records.value: + if type(record) is RecordTypeTXT and record.name == name: + self.assertEqual(record.text, new_record.text, msg='New record does not have the expected value') + found = True + + self.assertTrue(found, msg='New record not found in zone records') diff --git a/src/dns/api.rs b/src/dns/api.rs index bf5cf1d..19eda7a 100644 --- a/src/dns/api.rs +++ b/src/dns/api.rs @@ -10,7 +10,7 @@ pub trait RecordApi { async fn get_records(&mut self, zone: dns::Name, class: dns::DNSClass) -> Result, Self::Error>; async fn add_records(&mut self, zone: dns::Name, class: dns::DNSClass, new_records: Vec) -> Result<(), Self::Error>; - // update_records + async fn update_records(&mut self, zone: dns::Name, class: dns::DNSClass, old_records: Vec, new_records: Vec) -> Result<(), Self::Error>; // delete_records } @@ -22,6 +22,5 @@ pub trait ZoneApi { // get_zones // add_zone // delete_zone - // zone_exists async fn zone_exists(&mut self, zone: dns::Name, class: dns::DNSClass) -> Result<(), Self::Error>; } \ No newline at end of file diff --git a/src/dns/dns_api.rs b/src/dns/dns_api.rs index 2e6dd1d..9a12da3 100644 --- a/src/dns/dns_api.rs +++ b/src/dns/dns_api.rs @@ -1,8 +1,8 @@ use trust_dns_proto::DnsHandle; +use trust_dns_client::client::ClientHandle; use trust_dns_client::rr::{DNSClass, RecordType}; use trust_dns_client::op::{UpdateMessage, OpCode, MessageType, Message, Query, ResponseCode}; use trust_dns_client::error::ClientError; -use trust_dns_client::proto::xfer::{DnsRequestOptions}; use super::{Name, Record, RData}; use super::client::{ClientResponse, DnsClient}; @@ -11,12 +11,6 @@ use super::api::{RecordApi, ZoneApi}; #[derive(Debug)] pub enum DnsApiError { - RecordNotInZone { - zone: Name, - class: DNSClass, - mismatched_class: Vec, - mismatched_zone: Vec, - }, ClientError(ClientError), ResponceNotOk { code: ResponseCode, @@ -44,10 +38,9 @@ impl RecordApi for DnsApiClient { async fn get_records(&mut self, zone: Name, class: DNSClass) -> Result, Self::Error> { let response = { - let mut query = Query::query(zone.clone(), RecordType::AXFR); - query.set_query_class(class); - ClientResponse(self.client.lookup(query, DnsRequestOptions::default())).await.map_err(|e| DnsApiError::ClientError(e))? - }; + let query = self.client.query(zone.clone(), class, RecordType::AXFR); + query.await.map_err(|e| DnsApiError::ClientError(e))? + }; if response.response_code() != ResponseCode::NoError { return Err(DnsApiError::ResponceNotOk { @@ -68,31 +61,14 @@ impl RecordApi for DnsApiClient { async fn add_records(&mut self, zone: Name, class: DNSClass, new_records: Vec) -> Result<(), Self::Error> { - let mut mismatched_class = Vec::new(); - let mut mismatched_zone = Vec::new(); - - for record in new_records.iter() { - if !zone.zone_of(record.name()) { - mismatched_zone.push(record.clone()); - } - if record.dns_class() != class { - mismatched_class.push(record.clone()); - } - } - - if mismatched_class.len() > 0 || mismatched_zone.len() > 0 { - return Err(DnsApiError::RecordNotInZone { - zone, - class, - mismatched_zone, - mismatched_class - }) - } + // Taken from trust_dns_client::op::update_message::append + // The original function can not be used as is because it takes a RecordSet and not a Record list let mut zone_query = Query::new(); zone_query.set_name(zone.clone()) .set_query_class(class) .set_query_type(RecordType::SOA); + let mut message = Message::new(); // TODO: set random / time based id @@ -121,6 +97,68 @@ impl RecordApi for DnsApiClient { Ok(()) } + + async fn update_records(&mut self, zone: Name, class: DNSClass, old_records: Vec, new_records: Vec) -> Result<(), Self::Error> + { + + // Taken from trust_dns_client::op::update_message::compare_and_swap + // The original function can not be used as is because it takes a RecordSet and not a Record list + + // for updates, the query section is used for the zone + let mut zone_query: Query = Query::new(); + zone_query.set_name(zone.clone()) + .set_query_class(class) + .set_query_type(RecordType::SOA); + + let mut message: Message = Message::new(); + + // build the message + // TODO: set random / time based id + message + .set_id(0) + .set_message_type(MessageType::Query) + .set_op_code(OpCode::Update) + .set_recursion_desired(false); + message.add_zone(zone_query); + + // make sure the record is what is expected + let mut prerequisite = old_records.clone(); + for record in prerequisite.iter_mut() { + record.set_ttl(0); + } + message.add_pre_requisites(prerequisite); + + // add the delete for the old record + let mut delete = old_records; + // the class must be none for delete + for record in delete.iter_mut() { + record.set_dns_class(DNSClass::NONE); + // the TTL should be 0 + record.set_ttl(0); + } + message.add_updates(delete); + + // insert the new record... + message.add_updates(new_records); + + // Extended dns + { + let edns = message.edns_mut(); + edns.set_max_payload(1232); + edns.set_version(0); + } + + let response = ClientResponse(self.client.send(message)).await.map_err(|e| DnsApiError::ClientError(e))?; + + if response.response_code() != ResponseCode::NoError { + return Err(DnsApiError::ResponceNotOk { + code: response.response_code(), + zone: zone, + }); + } + + Ok(()) + } } @@ -131,10 +169,9 @@ impl ZoneApi for DnsApiClient { async fn zone_exists(&mut self, zone: Name, class: DNSClass) -> Result<(), Self::Error> { let response = { - let mut query = Query::query(zone.clone(), RecordType::SOA); - query.set_query_class(class); - ClientResponse(self.client.lookup(query, DnsRequestOptions::default())).await.map_err(|e| DnsApiError::ClientError(e))? - }; + let query = self.client.query(zone.clone(), class, RecordType::SOA); + query.await.map_err(|e| DnsApiError::ClientError(e))? + }; if response.response_code() != ResponseCode::NoError { return Err(DnsApiError::ResponceNotOk { diff --git a/src/dns/mod.rs b/src/dns/mod.rs index 73158c4..66f0c94 100644 --- a/src/dns/mod.rs +++ b/src/dns/mod.rs @@ -13,4 +13,5 @@ pub use trust_dns_proto::rr::Name; // Reexport module types pub use api::{RecordApi, ZoneApi}; -pub use dns_api::DnsApiClient; \ No newline at end of file +pub use dns_api::DnsApiClient; +pub use client::DnsClient; \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 03aea0b..af24f73 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,6 +28,7 @@ async fn rocket() -> rocket::Rocket { .mount("/api/v1", routes![ get_zone_records, create_zone_records, + update_zone_records, get_zones, create_zone, add_member_to_zone, diff --git a/src/models/class.rs b/src/models/class.rs index 385588d..7cb45d9 100644 --- a/src/models/class.rs +++ b/src/models/class.rs @@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize}; use crate::dns; -#[derive(Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone)] pub enum DNSClass { IN, CH, diff --git a/src/models/errors.rs b/src/models/errors.rs index 688f113..a8c8e85 100644 --- a/src/models/errors.rs +++ b/src/models/errors.rs @@ -102,19 +102,6 @@ impl From for ErrorResponse { impl From for ErrorResponse { fn from(e: DnsApiError) -> Self { match e { - DnsApiError::RecordNotInZone { zone, class, mismatched_class, mismatched_zone} => { - ErrorResponse::new( - Status::BadRequest, - "Record list contains records that do not belong to the zone".into() - ).with_details( - json!({ - "zone_name": zone.to_utf8(), - "class": models::DNSClass::from(class), - "mismatched_class": mismatched_class.into_iter().map(|r| r.clone().into()).collect::>(), - "mismatched_zone": mismatched_zone.into_iter().map(|r| r.clone().into()).collect::>(), - }) - ) - }, DnsApiError::ResponceNotOk { code, zone } => { println!("Query for zone {} failed with code {}", zone, code); @@ -132,15 +119,32 @@ impl From for ErrorResponse { impl From for ErrorResponse { fn from(e: models::RecordListParseError) -> Self { - models::ErrorResponse::new( - Status::BadRequest, - "Record list contains records that could not be parsed into DNS records".into() - ).with_details( - json!({ - "zone_name": e.zone.to_utf8(), - "records": e.bad_records - }) - ) + match e { + models::RecordListParseError::RecordNotInZone { zone, class, mismatched_class, mismatched_zone} => { + ErrorResponse::new( + Status::BadRequest, + "Record list contains records that do not belong to the zone".into() + ).with_details( + json!({ + "zone_name": zone.to_utf8(), + "class": models::DNSClass::from(class), + "mismatched_class": mismatched_class, + "mismatched_zone": mismatched_zone, + }) + ) + }, + models::RecordListParseError::ParseError { zone, bad_records } => { + ErrorResponse::new( + Status::BadRequest, + "Record list contains records that could not be parsed into DNS records".into() + ).with_details( + json!({ + "zone_name": zone.to_utf8(), + "records": bad_records + }) + ) + } + } } } diff --git a/src/models/mod.rs b/src/models/mod.rs index 32b6a4e..67a95b8 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -15,5 +15,5 @@ pub use errors::{UserError, ErrorResponse, make_500}; pub use name::{AbsoluteName, SerdeName}; pub use user::{LocalUser, UserInfo, Role, UserZone, User, CreateUserRequest}; pub use rdata::RData; -pub use record::{Record, RecordList, ParseRecordList, RecordListParseError}; +pub use record::{Record, RecordList, ParseRecordList, RecordListParseError, UpdateRecordsRequest}; pub use zone::{Zone, AddZoneMemberRequest, CreateZoneRequest}; \ No newline at end of file diff --git a/src/models/rdata.rs b/src/models/rdata.rs index 775bce8..34b81e7 100644 --- a/src/models/rdata.rs +++ b/src/models/rdata.rs @@ -11,7 +11,7 @@ use crate::dns; use super::name::SerdeName; -#[derive(Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone)] #[serde(tag = "Type")] #[serde(rename_all = "UPPERCASE")] pub enum RData { diff --git a/src/models/record.rs b/src/models/record.rs index 091a47d..8e51efd 100644 --- a/src/models/record.rs +++ b/src/models/record.rs @@ -8,7 +8,7 @@ use super::class::DNSClass; use super::rdata::RData; -#[derive(Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone)] pub struct Record { #[serde(rename = "Name")] pub name: SerdeName, @@ -45,36 +45,78 @@ impl TryFrom for dns::Record { pub type RecordList = Vec; -pub struct RecordListParseError { - pub bad_records: Vec, - pub zone: dns::Name, +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdateRecordsRequest { + pub old_records: RecordList, + pub new_records: RecordList, +} + +pub enum RecordListParseError { + ParseError { + bad_records: Vec, + zone: dns::Name, + }, + RecordNotInZone { + zone: dns::Name, + class: dns::DNSClass, + mismatched_class: Vec, + mismatched_zone: Vec, + }, } pub trait ParseRecordList { - fn try_into_dns_type(self, zone: dns::Name) -> Result, RecordListParseError>; + fn try_into_dns_type(self, zone: dns::Name, class: dns::DNSClass) -> Result, RecordListParseError>; } impl ParseRecordList for RecordList { - fn try_into_dns_type(self, zone: dns::Name) -> Result, RecordListParseError> { + fn try_into_dns_type(self, zone: dns::Name, class: dns::DNSClass) -> Result, RecordListParseError> { // TODO: What about relative names (also in cnames and stuff) let mut bad_records = Vec::new(); let mut records: Vec = Vec::new(); + let mut mismatched_class: Vec = Vec::new(); + let mut mismatched_zone: Vec = Vec::new(); for record in self.into_iter() { let this_record = record.clone(); - if let Ok(record) = record.try_into() { - records.push(record); + if let Ok(record) = dns::Record::try_from(record) { + let mut good_record = true; + + if !zone.zone_of(record.name()) { + mismatched_zone.push(this_record.clone()); + good_record = false; + } + + if record.dns_class() != class { + mismatched_class.push(this_record.clone()); + good_record = false; + } + + if good_record { + records.push(record); + } } else { bad_records.push(this_record.clone()); } } if !bad_records.is_empty() { - return Err(RecordListParseError { + return Err(RecordListParseError::ParseError { zone, bad_records, }); } + + if !mismatched_class.is_empty() || !mismatched_zone.is_empty() { + return Err(RecordListParseError::RecordNotInZone { + zone, + class, + mismatched_zone, + mismatched_class + }); + } + return Ok(records) } -} \ No newline at end of file +} + diff --git a/src/routes/zones.rs b/src/routes/zones.rs index ca91a21..ef5827f 100644 --- a/src/routes/zones.rs +++ b/src/routes/zones.rs @@ -4,19 +4,18 @@ use rocket::http::Status; use rocket_contrib::json::Json; use crate::DbConn; -use crate::dns; +use crate::dns::{DnsClient, DnsApiClient, RecordApi, ZoneApi}; use crate::models; -use crate::dns::{RecordApi, ZoneApi}; use crate::models::{ParseRecordList}; #[get("/zones//records")] pub async fn get_zone_records( - client: dns::client::DnsClient, + client: DnsClient, conn: DbConn, user_info: Result, zone: models::AbsoluteName -) -> Result>, models::ErrorResponse> { +) -> Result, models::ErrorResponse> { let user_info = user_info?; let zone_name = zone.to_string(); @@ -29,9 +28,9 @@ pub async fn get_zone_records( } }).await?; - let mut dns_api = dns::DnsApiClient::new(client); + let mut dns_api = DnsApiClient::new(client); - let dns_records = dns_api.get_records(zone.clone(), dns::DNSClass::IN).await?; + let dns_records = dns_api.get_records(zone.clone(), models::DNSClass::IN.into()).await?; let records: Vec<_> = dns_records.into_iter().map(models::Record::from).collect(); Ok(Json(records)) @@ -39,11 +38,11 @@ pub async fn get_zone_records( #[post("/zones//records", data = "")] pub async fn create_zone_records( - client: dns::client::DnsClient, + client: DnsClient, conn: DbConn, user_info: Result, zone: models::AbsoluteName, - new_records: Json> + new_records: Json ) -> Result, models::ErrorResponse> { let user_info = user_info?; @@ -58,17 +57,53 @@ pub async fn create_zone_records( }).await?; - let mut dns_api = dns::DnsApiClient::new(client); + let mut dns_api = DnsApiClient::new(client); dns_api.add_records( zone.clone(), models::DNSClass::IN.into(), - new_records.into_inner().try_into_dns_type(zone.into_inner())? + new_records.into_inner().try_into_dns_type(zone.into_inner(), models::DNSClass::IN.into())? ).await?; return Ok(Json(())); } +#[put("/zones//records", data = "")] +pub async fn update_zone_records( + client: DnsClient, + conn: DbConn, + user_info: Result, + zone: models::AbsoluteName, + update_records_request: Json +) -> Result, models::ErrorResponse> { + + let user_info = user_info?; + let zone = zone.into_inner(); + let zone_name = zone.to_utf8(); + let update_records_request = update_records_request.into_inner(); + + conn.run(move |c| { + if user_info.is_admin() { + models::Zone::get_by_name(c, &zone_name) + } else { + user_info.get_zone(c, &zone_name) + } + }).await?; + + + let mut dns_api = DnsApiClient::new(client); + + dns_api.update_records( + zone.clone(), + models::DNSClass::IN.into(), + update_records_request.old_records.try_into_dns_type(zone.clone(), models::DNSClass::IN.into())?, + update_records_request.new_records.try_into_dns_type(zone, models::DNSClass::IN.into())?, + ).await?; + + return Ok(Json(())); +} + + #[get("/zones")] pub async fn get_zones( conn: DbConn, @@ -90,14 +125,14 @@ pub async fn get_zones( #[post("/zones", data = "")] pub async fn create_zone( conn: DbConn, - client: dns::client::DnsClient, + client: DnsClient, user_info: Result, zone_request: Json, ) -> Result, models::ErrorResponse> { user_info?.check_admin()?; - let mut dns_api = dns::DnsApiClient::new(client); - dns_api.zone_exists(zone_request.name.clone(), dns::DNSClass::IN).await?; + let mut dns_api = DnsApiClient::new(client); + dns_api.zone_exists(zone_request.name.clone(), models::DNSClass::IN.into()).await?; let zone = conn.run(move |c| { models::Zone::create_zone(c, zone_request.into_inner()) From 3d05220f943d5b6fd9a139e88a1cac351874f4a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Berthaud-M=C3=BCller?= Date: Fri, 4 Mar 2022 22:01:57 +0100 Subject: [PATCH 15/19] add testing doc --- docs/Testing.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 docs/Testing.md diff --git a/docs/Testing.md b/docs/Testing.md new file mode 100644 index 0000000..9a9e475 --- /dev/null +++ b/docs/Testing.md @@ -0,0 +1,17 @@ +# Testing + +To run the end-to-end tests the OpenAPI Python client should be generated first: +``` +openapi-generator generate -i ./api.yml -g python --package-name nomilo_client -o ./python_client +``` + +Then install, here a virtual env is created for this purpose: +``` +python -m venv env +env/bin/pip install ./python_client +``` + +You are now all set to run the e2e tests, note that Nomilo must be started first: +``` +env/bin/python -m unittest e2e/*.py +``` \ No newline at end of file From 5fb6545470b035c251aa5d79f46c929e0b17d20e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Berthaud-M=C3=BCller?= Date: Fri, 4 Mar 2022 22:07:22 +0100 Subject: [PATCH 16/19] update doc --- docs/Testing.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/Testing.md b/docs/Testing.md index 9a9e475..891bbed 100644 --- a/docs/Testing.md +++ b/docs/Testing.md @@ -5,13 +5,18 @@ To run the end-to-end tests the OpenAPI Python client should be generated first: openapi-generator generate -i ./api.yml -g python --package-name nomilo_client -o ./python_client ``` -Then install, here a virtual env is created for this purpose: +Then install the client, here a virtual env is created for this purpose: ``` python -m venv env env/bin/pip install ./python_client ``` -You are now all set to run the e2e tests, note that Nomilo must be started first: +Finally start the name server. It will listen on `127.0.0.1:5353`, be sure to update the configuration accordingly. +``` +docker-compose -f ./dev-scripts/docker-compose.yml up -d +``` + +You are now all set to run the e2e tests. Note that Nomilo must be started first. ``` env/bin/python -m unittest e2e/*.py ``` \ No newline at end of file From 5f18e32615b5d37fcb7b144f308387e0d1fa6b3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Berthaud-M=C3=BCller?= Date: Fri, 4 Mar 2022 22:24:37 +0100 Subject: [PATCH 17/19] fix identation --- src/dns/dns_api.rs | 246 ++++++++++++++++++++++----------------------- 1 file changed, 123 insertions(+), 123 deletions(-) diff --git a/src/dns/dns_api.rs b/src/dns/dns_api.rs index 9a12da3..9118515 100644 --- a/src/dns/dns_api.rs +++ b/src/dns/dns_api.rs @@ -11,176 +11,176 @@ use super::api::{RecordApi, ZoneApi}; #[derive(Debug)] pub enum DnsApiError { - ClientError(ClientError), - ResponceNotOk { - code: ResponseCode, - zone: Name, - }, + ClientError(ClientError), + ResponceNotOk { + code: ResponseCode, + zone: Name, + }, } pub struct DnsApiClient { - client: DnsClient + client: DnsClient } impl DnsApiClient { - pub fn new(client: DnsClient) -> Self { - DnsApiClient { - client - } - } + pub fn new(client: DnsClient) -> Self { + DnsApiClient { + client + } + } } #[async_trait] impl RecordApi for DnsApiClient { - type Error = DnsApiError; + type Error = DnsApiError; - async fn get_records(&mut self, zone: Name, class: DNSClass) -> Result, Self::Error> - { - let response = { - let query = self.client.query(zone.clone(), class, RecordType::AXFR); - query.await.map_err(|e| DnsApiError::ClientError(e))? - }; + async fn get_records(&mut self, zone: Name, class: DNSClass) -> Result, Self::Error> + { + let response = { + let query = self.client.query(zone.clone(), class, RecordType::AXFR); + query.await.map_err(|e| DnsApiError::ClientError(e))? + }; - if response.response_code() != ResponseCode::NoError { - return Err(DnsApiError::ResponceNotOk { - code: response.response_code(), - zone: zone, - }); - } + if response.response_code() != ResponseCode::NoError { + return Err(DnsApiError::ResponceNotOk { + code: response.response_code(), + zone: zone, + }); + } - let answers = response.answers(); - let mut records: Vec<_> = answers.to_vec().into_iter() - .filter(|record| !matches!(record.rdata(), RData::NULL { .. } | RData::DNSSEC(_))) - .collect(); + let answers = response.answers(); + let mut records: Vec<_> = answers.to_vec().into_iter() + .filter(|record| !matches!(record.rdata(), RData::NULL { .. } | RData::DNSSEC(_))) + .collect(); - // AXFR response ends with SOA, we remove it so it is not doubled in the response. - records.pop(); - Ok(records) - } + // AXFR response ends with SOA, we remove it so it is not doubled in the response. + records.pop(); + Ok(records) + } - async fn add_records(&mut self, zone: Name, class: DNSClass, new_records: Vec) -> Result<(), Self::Error> - { - // Taken from trust_dns_client::op::update_message::append - // The original function can not be used as is because it takes a RecordSet and not a Record list + async fn add_records(&mut self, zone: Name, class: DNSClass, new_records: Vec) -> Result<(), Self::Error> + { + // Taken from trust_dns_client::op::update_message::append + // The original function can not be used as is because it takes a RecordSet and not a Record list - let mut zone_query = Query::new(); - zone_query.set_name(zone.clone()) - .set_query_class(class) - .set_query_type(RecordType::SOA); + let mut zone_query = Query::new(); + zone_query.set_name(zone.clone()) + .set_query_class(class) + .set_query_type(RecordType::SOA); - let mut message = Message::new(); + let mut message = Message::new(); - // TODO: set random / time based id - message - .set_id(0) - .set_message_type(MessageType::Query) - .set_op_code(OpCode::Update) - .set_recursion_desired(false); - message.add_zone(zone_query); - message.add_updates(new_records); + // TODO: set random / time based id + message + .set_id(0) + .set_message_type(MessageType::Query) + .set_op_code(OpCode::Update) + .set_recursion_desired(false); + message.add_zone(zone_query); + message.add_updates(new_records); - { - let edns = message.edns_mut(); - edns.set_max_payload(1232); - edns.set_version(0); - } + { + let edns = message.edns_mut(); + edns.set_max_payload(1232); + edns.set_version(0); + } let response = ClientResponse(self.client.send(message)).await.map_err(|e| DnsApiError::ClientError(e))?; if response.response_code() != ResponseCode::NoError { - return Err(DnsApiError::ResponceNotOk { - code: response.response_code(), - zone: zone, - }); - } + return Err(DnsApiError::ResponceNotOk { + code: response.response_code(), + zone: zone, + }); + } Ok(()) - } + } - async fn update_records(&mut self, zone: Name, class: DNSClass, old_records: Vec, new_records: Vec) -> Result<(), Self::Error> - { + async fn update_records(&mut self, zone: Name, class: DNSClass, old_records: Vec, new_records: Vec) -> Result<(), Self::Error> + { - // Taken from trust_dns_client::op::update_message::compare_and_swap - // The original function can not be used as is because it takes a RecordSet and not a Record list + // Taken from trust_dns_client::op::update_message::compare_and_swap + // The original function can not be used as is because it takes a RecordSet and not a Record list - // for updates, the query section is used for the zone - let mut zone_query: Query = Query::new(); - zone_query.set_name(zone.clone()) - .set_query_class(class) - .set_query_type(RecordType::SOA); + // for updates, the query section is used for the zone + let mut zone_query: Query = Query::new(); + zone_query.set_name(zone.clone()) + .set_query_class(class) + .set_query_type(RecordType::SOA); - let mut message: Message = Message::new(); + let mut message: Message = Message::new(); - // build the message - // TODO: set random / time based id - message - .set_id(0) - .set_message_type(MessageType::Query) - .set_op_code(OpCode::Update) - .set_recursion_desired(false); - message.add_zone(zone_query); + // build the message + // TODO: set random / time based id + message + .set_id(0) + .set_message_type(MessageType::Query) + .set_op_code(OpCode::Update) + .set_recursion_desired(false); + message.add_zone(zone_query); - // make sure the record is what is expected - let mut prerequisite = old_records.clone(); - for record in prerequisite.iter_mut() { - record.set_ttl(0); - } - message.add_pre_requisites(prerequisite); + // make sure the record is what is expected + let mut prerequisite = old_records.clone(); + for record in prerequisite.iter_mut() { + record.set_ttl(0); + } + message.add_pre_requisites(prerequisite); - // add the delete for the old record - let mut delete = old_records; - // the class must be none for delete - for record in delete.iter_mut() { - record.set_dns_class(DNSClass::NONE); - // the TTL should be 0 - record.set_ttl(0); - } - message.add_updates(delete); + // add the delete for the old record + let mut delete = old_records; + // the class must be none for delete + for record in delete.iter_mut() { + record.set_dns_class(DNSClass::NONE); + // the TTL should be 0 + record.set_ttl(0); + } + message.add_updates(delete); - // insert the new record... - message.add_updates(new_records); + // insert the new record... + message.add_updates(new_records); - // Extended dns - { - let edns = message.edns_mut(); - edns.set_max_payload(1232); - edns.set_version(0); - } + // Extended dns + { + let edns = message.edns_mut(); + edns.set_max_payload(1232); + edns.set_version(0); + } - let response = ClientResponse(self.client.send(message)).await.map_err(|e| DnsApiError::ClientError(e))?; + let response = ClientResponse(self.client.send(message)).await.map_err(|e| DnsApiError::ClientError(e))?; if response.response_code() != ResponseCode::NoError { - return Err(DnsApiError::ResponceNotOk { - code: response.response_code(), - zone: zone, - }); - } + return Err(DnsApiError::ResponceNotOk { + code: response.response_code(), + zone: zone, + }); + } Ok(()) - } + } } #[async_trait] impl ZoneApi for DnsApiClient { - type Error = DnsApiError; + type Error = DnsApiError; - async fn zone_exists(&mut self, zone: Name, class: DNSClass) -> Result<(), Self::Error> - { - let response = { - let query = self.client.query(zone.clone(), class, RecordType::SOA); - query.await.map_err(|e| DnsApiError::ClientError(e))? - }; + async fn zone_exists(&mut self, zone: Name, class: DNSClass) -> Result<(), Self::Error> + { + let response = { + let query = self.client.query(zone.clone(), class, RecordType::SOA); + query.await.map_err(|e| DnsApiError::ClientError(e))? + }; - if response.response_code() != ResponseCode::NoError { - return Err(DnsApiError::ResponceNotOk { - code: response.response_code(), - zone: zone, - }); - } + if response.response_code() != ResponseCode::NoError { + return Err(DnsApiError::ResponceNotOk { + code: response.response_code(), + zone: zone, + }); + } - Ok(()) - } + Ok(()) + } } \ No newline at end of file From bc77bb16d474c7a15c620830f2e211487bce8db5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Berthaud-M=C3=BCller?= Date: Sat, 5 Mar 2022 13:07:51 +0100 Subject: [PATCH 18/19] add delete route --- api.yml | 12 +++++++++ e2e/zones.py | 25 +++++++++++++++++-- src/dns/api.rs | 1 + src/dns/dns_api.rs | 61 +++++++++++++++++++++++++++++++++++++++++---- src/main.rs | 3 ++- src/routes/zones.rs | 32 ++++++++++++++++++++++-- 6 files changed, 124 insertions(+), 10 deletions(-) diff --git a/api.yml b/api.yml index 13913e4..fdef762 100644 --- a/api.yml +++ b/api.yml @@ -423,3 +423,15 @@ paths: '200': description: '' + delete: + security: + - ApiToken: [] + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/RecordList' + responses: + '200': + description: '' + diff --git a/e2e/zones.py b/e2e/zones.py index 2867df9..d4784de 100644 --- a/e2e/zones.py +++ b/e2e/zones.py @@ -117,7 +117,7 @@ class TestZones(unittest.TestCase): type='TXT' ) - self.api.zones_zone_records_post(zone='example.com.', record_list=RecordList(value=[old_record])) + self.api.zones_zone_records_post(zone='example.com.', record_list=RecordList([old_record])) update_records_request = UpdateRecordsRequest( old_records=RecordList([old_record]), @@ -133,4 +133,25 @@ class TestZones(unittest.TestCase): self.assertEqual(record.text, new_record.text, msg='New record does not have the expected value') found = True - self.assertTrue(found, msg='New record not found in zone records') + self.assertTrue(found, msg='Updated record not found in zone records') + + def test_delete_records(self): + name = random_name('example.com.') + record = RecordTypeTXT( + _class='IN', + ttl=300, + name=name, + text=random_string(32), + type='TXT' + ) + + self.api.zones_zone_records_post(zone='example.com.', record_list=RecordList([record])) + self.api.zones_zone_records_delete(zone='example.com.', record_list=RecordList([record])) + + records = self.api.zones_zone_records_get(zone='example.com.') + found = False + for record in records.value: + if type(record) is RecordTypeTXT and record.name == name: + found = True + + self.assertFalse(found, msg='Delete record found in zone records') \ No newline at end of file diff --git a/src/dns/api.rs b/src/dns/api.rs index 19eda7a..c5a6d84 100644 --- a/src/dns/api.rs +++ b/src/dns/api.rs @@ -11,6 +11,7 @@ pub trait RecordApi { async fn get_records(&mut self, zone: dns::Name, class: dns::DNSClass) -> Result, Self::Error>; async fn add_records(&mut self, zone: dns::Name, class: dns::DNSClass, new_records: Vec) -> Result<(), Self::Error>; async fn update_records(&mut self, zone: dns::Name, class: dns::DNSClass, old_records: Vec, new_records: Vec) -> Result<(), Self::Error>; + async fn delete_records(&mut self, zone: dns::Name, class: dns::DNSClass, records: Vec) -> Result<(), Self::Error>; // delete_records } diff --git a/src/dns/dns_api.rs b/src/dns/dns_api.rs index 9118515..2546ce2 100644 --- a/src/dns/dns_api.rs +++ b/src/dns/dns_api.rs @@ -9,6 +9,9 @@ use super::client::{ClientResponse, DnsClient}; use super::api::{RecordApi, ZoneApi}; +const MAX_PAYLOAD_LEN: u16 = 1232; + + #[derive(Debug)] pub enum DnsApiError { ClientError(ClientError), @@ -82,7 +85,7 @@ impl RecordApi for DnsApiClient { { let edns = message.edns_mut(); - edns.set_max_payload(1232); + edns.set_max_payload(MAX_PAYLOAD_LEN); edns.set_version(0); } @@ -100,12 +103,11 @@ impl RecordApi for DnsApiClient { async fn update_records(&mut self, zone: Name, class: DNSClass, old_records: Vec, new_records: Vec) -> Result<(), Self::Error> { - // Taken from trust_dns_client::op::update_message::compare_and_swap // The original function can not be used as is because it takes a RecordSet and not a Record list // for updates, the query section is used for the zone - let mut zone_query: Query = Query::new(); + let mut zone_query = Query::new(); zone_query.set_name(zone.clone()) .set_query_class(class) .set_query_type(RecordType::SOA); @@ -130,8 +132,8 @@ impl RecordApi for DnsApiClient { // add the delete for the old record let mut delete = old_records; - // the class must be none for delete for record in delete.iter_mut() { + // the class must be none for delete record.set_dns_class(DNSClass::NONE); // the TTL should be 0 record.set_ttl(0); @@ -144,7 +146,7 @@ impl RecordApi for DnsApiClient { // Extended dns { let edns = message.edns_mut(); - edns.set_max_payload(1232); + edns.set_max_payload(MAX_PAYLOAD_LEN); edns.set_version(0); } @@ -159,6 +161,55 @@ impl RecordApi for DnsApiClient { Ok(()) } + + async fn delete_records(&mut self, zone: Name, class: DNSClass, records: Vec) -> Result<(), Self::Error> + { + // for updates, the query section is used for the zone + let mut zone_query = Query::new(); + zone_query.set_name(zone.clone()) + .set_query_class(class) + .set_query_type(RecordType::SOA); + + let mut message: Message = Message::new(); + + // build the message + // TODO: set random / time based id + message + .set_id(0) + .set_message_type(MessageType::Query) + .set_op_code(OpCode::Update) + .set_recursion_desired(false); + message.add_zone(zone_query); + + let mut delete = records; + + for record in delete.iter_mut() { + // the class must be none for delete + record.set_dns_class(DNSClass::NONE); + // the TTL should be 0 + record.set_ttl(0); + } + message.add_updates(delete); + + // Extended dns + { + let edns = message.edns_mut(); + edns.set_max_payload(MAX_PAYLOAD_LEN); + edns.set_version(0); + } + + let response = ClientResponse(self.client.send(message)).await.map_err(|e| DnsApiError::ClientError(e))?; + + if response.response_code() != ResponseCode::NoError { + return Err(DnsApiError::ResponceNotOk { + code: response.response_code(), + zone: zone, + }); + } + + Ok(()) + + } } diff --git a/src/main.rs b/src/main.rs index af24f73..4870eee 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,10 +29,11 @@ async fn rocket() -> rocket::Rocket { get_zone_records, create_zone_records, update_zone_records, + delete_zone_records, get_zones, create_zone, add_member_to_zone, create_auth_token, - create_user + create_user, ]) } diff --git a/src/routes/zones.rs b/src/routes/zones.rs index ef5827f..c10b478 100644 --- a/src/routes/zones.rs +++ b/src/routes/zones.rs @@ -56,7 +56,6 @@ pub async fn create_zone_records( } }).await?; - let mut dns_api = DnsApiClient::new(client); dns_api.add_records( @@ -90,7 +89,6 @@ pub async fn update_zone_records( } }).await?; - let mut dns_api = DnsApiClient::new(client); dns_api.update_records( @@ -103,6 +101,36 @@ pub async fn update_zone_records( return Ok(Json(())); } +#[delete("/zones//records", data = "")] +pub async fn delete_zone_records( + client: DnsClient, + conn: DbConn, + user_info: Result, + zone: models::AbsoluteName, + records: Json +) -> Result, models::ErrorResponse> { + + let user_info = user_info?; + let zone_name = zone.to_utf8(); + + conn.run(move |c| { + if user_info.is_admin() { + models::Zone::get_by_name(c, &zone_name) + } else { + user_info.get_zone(c, &zone_name) + } + }).await?; + + let mut dns_api = DnsApiClient::new(client); + + dns_api.delete_records( + zone.clone(), + models::DNSClass::IN.into(), + records.into_inner().try_into_dns_type(zone.into_inner(), models::DNSClass::IN.into())? + ).await?; + + return Ok(Json(())); +} #[get("/zones")] pub async fn get_zones( From 48c62d102007be28041a815ab8206ad33694fea6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20Berthaud-M=C3=BCller?= Date: Sat, 5 Mar 2022 13:19:31 +0100 Subject: [PATCH 19/19] rename connector types --- src/dns/{api.rs => connector.rs} | 4 +-- src/dns/{dns_api.rs => dns_connector.rs} | 39 ++++++++++++------------ src/dns/mod.rs | 8 ++--- src/models/errors.rs | 10 +++--- src/routes/zones.rs | 12 ++++---- 5 files changed, 36 insertions(+), 37 deletions(-) rename src/dns/{api.rs => connector.rs} (95%) rename src/dns/{dns_api.rs => dns_connector.rs} (87%) diff --git a/src/dns/api.rs b/src/dns/connector.rs similarity index 95% rename from src/dns/api.rs rename to src/dns/connector.rs index c5a6d84..70f75e8 100644 --- a/src/dns/api.rs +++ b/src/dns/connector.rs @@ -5,7 +5,7 @@ use crate::dns; // Zone content api // E.g.: DNS update + axfr, zone file read + write #[async_trait] -pub trait RecordApi { +pub trait RecordConnector { type Error; async fn get_records(&mut self, zone: dns::Name, class: dns::DNSClass) -> Result, Self::Error>; @@ -18,7 +18,7 @@ pub trait RecordApi { // Zone management api, todo // E.g.: Manage catalog zone, dynamically generate knot / bind / nsd config... #[async_trait] -pub trait ZoneApi { +pub trait ZoneConnector { type Error; // get_zones // add_zone diff --git a/src/dns/dns_api.rs b/src/dns/dns_connector.rs similarity index 87% rename from src/dns/dns_api.rs rename to src/dns/dns_connector.rs index 2546ce2..aad60e5 100644 --- a/src/dns/dns_api.rs +++ b/src/dns/dns_connector.rs @@ -6,14 +6,14 @@ use trust_dns_client::error::ClientError; use super::{Name, Record, RData}; use super::client::{ClientResponse, DnsClient}; -use super::api::{RecordApi, ZoneApi}; +use super::connector::{RecordConnector, ZoneConnector}; const MAX_PAYLOAD_LEN: u16 = 1232; #[derive(Debug)] -pub enum DnsApiError { +pub enum DnsConnectorError { ClientError(ClientError), ResponceNotOk { code: ResponseCode, @@ -21,13 +21,13 @@ pub enum DnsApiError { }, } -pub struct DnsApiClient { +pub struct DnsConnectorClient { client: DnsClient } -impl DnsApiClient { +impl DnsConnectorClient { pub fn new(client: DnsClient) -> Self { - DnsApiClient { + DnsConnectorClient { client } } @@ -35,18 +35,18 @@ impl DnsApiClient { #[async_trait] -impl RecordApi for DnsApiClient { - type Error = DnsApiError; +impl RecordConnector for DnsConnectorClient { + type Error = DnsConnectorError; async fn get_records(&mut self, zone: Name, class: DNSClass) -> Result, Self::Error> { let response = { let query = self.client.query(zone.clone(), class, RecordType::AXFR); - query.await.map_err(|e| DnsApiError::ClientError(e))? + query.await.map_err(|e| DnsConnectorError::ClientError(e))? }; if response.response_code() != ResponseCode::NoError { - return Err(DnsApiError::ResponceNotOk { + return Err(DnsConnectorError::ResponceNotOk { code: response.response_code(), zone: zone, }); @@ -89,10 +89,10 @@ impl RecordApi for DnsApiClient { edns.set_version(0); } - let response = ClientResponse(self.client.send(message)).await.map_err(|e| DnsApiError::ClientError(e))?; + let response = ClientResponse(self.client.send(message)).await.map_err(|e| DnsConnectorError::ClientError(e))?; if response.response_code() != ResponseCode::NoError { - return Err(DnsApiError::ResponceNotOk { + return Err(DnsConnectorError::ResponceNotOk { code: response.response_code(), zone: zone, }); @@ -150,10 +150,10 @@ impl RecordApi for DnsApiClient { edns.set_version(0); } - let response = ClientResponse(self.client.send(message)).await.map_err(|e| DnsApiError::ClientError(e))?; + let response = ClientResponse(self.client.send(message)).await.map_err(|e| DnsConnectorError::ClientError(e))?; if response.response_code() != ResponseCode::NoError { - return Err(DnsApiError::ResponceNotOk { + return Err(DnsConnectorError::ResponceNotOk { code: response.response_code(), zone: zone, }); @@ -182,7 +182,6 @@ impl RecordApi for DnsApiClient { message.add_zone(zone_query); let mut delete = records; - for record in delete.iter_mut() { // the class must be none for delete record.set_dns_class(DNSClass::NONE); @@ -198,10 +197,10 @@ impl RecordApi for DnsApiClient { edns.set_version(0); } - let response = ClientResponse(self.client.send(message)).await.map_err(|e| DnsApiError::ClientError(e))?; + let response = ClientResponse(self.client.send(message)).await.map_err(|e| DnsConnectorError::ClientError(e))?; if response.response_code() != ResponseCode::NoError { - return Err(DnsApiError::ResponceNotOk { + return Err(DnsConnectorError::ResponceNotOk { code: response.response_code(), zone: zone, }); @@ -214,18 +213,18 @@ impl RecordApi for DnsApiClient { #[async_trait] -impl ZoneApi for DnsApiClient { - type Error = DnsApiError; +impl ZoneConnector for DnsConnectorClient { + type Error = DnsConnectorError; async fn zone_exists(&mut self, zone: Name, class: DNSClass) -> Result<(), Self::Error> { let response = { let query = self.client.query(zone.clone(), class, RecordType::SOA); - query.await.map_err(|e| DnsApiError::ClientError(e))? + query.await.map_err(|e| DnsConnectorError::ClientError(e))? }; if response.response_code() != ResponseCode::NoError { - return Err(DnsApiError::ResponceNotOk { + return Err(DnsConnectorError::ResponceNotOk { code: response.response_code(), zone: zone, }); diff --git a/src/dns/mod.rs b/src/dns/mod.rs index 66f0c94..a5fc33d 100644 --- a/src/dns/mod.rs +++ b/src/dns/mod.rs @@ -1,6 +1,6 @@ pub mod client; -pub mod dns_api; -pub mod api; +pub mod dns_connector; +pub mod connector; // Reexport trust dns types for convenience pub use trust_dns_client::rr::rdata::{ @@ -12,6 +12,6 @@ pub use trust_dns_client::rr::{ pub use trust_dns_proto::rr::Name; // Reexport module types -pub use api::{RecordApi, ZoneApi}; -pub use dns_api::DnsApiClient; +pub use connector::{RecordConnector, ZoneConnector}; +pub use dns_connector::{DnsConnectorClient, DnsConnectorError}; pub use client::DnsClient; \ No newline at end of file diff --git a/src/models/errors.rs b/src/models/errors.rs index a8c8e85..18ff43c 100644 --- a/src/models/errors.rs +++ b/src/models/errors.rs @@ -6,7 +6,7 @@ use rocket_contrib::json::Json; use serde_json::Value; use djangohashers::{HasherError}; use diesel::result::Error as DieselError; -use crate::dns::dns_api::DnsApiError; +use crate::dns::DnsConnectorError; use crate::models; #[derive(Debug)] @@ -99,10 +99,10 @@ impl From for ErrorResponse { } } -impl From for ErrorResponse { - fn from(e: DnsApiError) -> Self { +impl From for ErrorResponse { + fn from(e: DnsConnectorError) -> Self { match e { - DnsApiError::ResponceNotOk { code, zone } => { + DnsConnectorError::ResponceNotOk { code, zone } => { println!("Query for zone {} failed with code {}", zone, code); ErrorResponse::new( @@ -112,7 +112,7 @@ impl From for ErrorResponse { "zone_name": zone.to_utf8() })) }, - DnsApiError::ClientError(e) => make_500(e) + DnsConnectorError::ClientError(e) => make_500(e) } } } diff --git a/src/routes/zones.rs b/src/routes/zones.rs index c10b478..94cdd89 100644 --- a/src/routes/zones.rs +++ b/src/routes/zones.rs @@ -4,7 +4,7 @@ use rocket::http::Status; use rocket_contrib::json::Json; use crate::DbConn; -use crate::dns::{DnsClient, DnsApiClient, RecordApi, ZoneApi}; +use crate::dns::{DnsClient, DnsConnectorClient, RecordConnector, ZoneConnector}; use crate::models; use crate::models::{ParseRecordList}; @@ -28,7 +28,7 @@ pub async fn get_zone_records( } }).await?; - let mut dns_api = DnsApiClient::new(client); + let mut dns_api = DnsConnectorClient::new(client); let dns_records = dns_api.get_records(zone.clone(), models::DNSClass::IN.into()).await?; let records: Vec<_> = dns_records.into_iter().map(models::Record::from).collect(); @@ -56,7 +56,7 @@ pub async fn create_zone_records( } }).await?; - let mut dns_api = DnsApiClient::new(client); + let mut dns_api = DnsConnectorClient::new(client); dns_api.add_records( zone.clone(), @@ -89,7 +89,7 @@ pub async fn update_zone_records( } }).await?; - let mut dns_api = DnsApiClient::new(client); + let mut dns_api = DnsConnectorClient::new(client); dns_api.update_records( zone.clone(), @@ -121,7 +121,7 @@ pub async fn delete_zone_records( } }).await?; - let mut dns_api = DnsApiClient::new(client); + let mut dns_api = DnsConnectorClient::new(client); dns_api.delete_records( zone.clone(), @@ -159,7 +159,7 @@ pub async fn create_zone( ) -> Result, models::ErrorResponse> { user_info?.check_admin()?; - let mut dns_api = DnsApiClient::new(client); + let mut dns_api = DnsConnectorClient::new(client); dns_api.zone_exists(zone_request.name.clone(), models::DNSClass::IN.into()).await?; let zone = conn.run(move |c| {