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] 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())