diff --git a/README.md b/README.md index 52c8a05..3063a3e 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ Currently, the following DDNS providers are supported: * DuckDNS * Dynu * IPv64 +* Linode * NoIP * Porkbun * selfHOST.de diff --git a/docs/config.toml b/docs/config.toml index 04607f3..3748421 100644 --- a/docs/config.toml +++ b/docs/config.toml @@ -77,7 +77,7 @@ # # If you are using this method, make sure your update rate is long enough # so that you are not banned by the HTTP service you are using (10 to - # 30 minutes is recommended). + # 30 minutes is recommended). url = "https://api6.ipify.org/" regex = "(.*)" @@ -99,7 +99,7 @@ # In the case of dual-stacking (IPv4+IPv6), the first usable IPv4 address and # the first usable IPv6 address will be used to update the record. # -# NOTE that some services require you to pre-create DNS records on their own +# NOTE that some services require you to pre-create DNS records on their own # website, notably Porkbun (the edit operation will succeed, but no actual # changes are made). Best to create your A and/or AAAA records before starting # the daemon. @@ -117,7 +117,7 @@ # This uses Cloudflare API v4 to update the domains. # Your token must have the permissions "Zone - DNS - Edit" and - # "Zone - Zone - Read" enabled for the zone your domain is located in. + # "Zone - Zone - Read" enabled for the zone your domain is located in. token = "" ttl = 300 proxied = true @@ -161,11 +161,19 @@ password = "ihrer-token-hier" domains = "example.com" +[ddns."linode-example"] + service = "linode" + ip = ["name1", "name2"] + + token = "your-token" + domains = ["example.com", "sub.example.com"] + ttl = 300 + [ddns."porkbun-example"] service = "porkbun-v3" ip = ["name1", "name2"] - # Remember to enable API access on your domain. + # Remember to enable API access on your domain. secret_api_key = "sk1_key" api_key = "pk1_key" domains = ["example.com"] diff --git a/src/config.rs b/src/config.rs index 481942e..1f07635 100644 --- a/src/config.rs +++ b/src/config.rs @@ -36,7 +36,7 @@ pub enum IpConfigMethod { Http { url: Box, - + #[serde(default = "default_regex")] regex: Box, }, @@ -65,6 +65,7 @@ pub enum DdnsConfigService { Duckdns(duckdns::Config), Dynu(dynu::Config), Ipv64(dynu::Config), + Linode(linode::Config), PorkbunV3(porkbun::Config), Selfhost(dynu::Config), NoIp(noip::Config), @@ -86,6 +87,8 @@ impl DdnsConfigService { DdnsConfigService::Ipv64(ip) => Box::new(ipv64::Service::from(ip)), + DdnsConfigService::Linode(li) => Box::new(linode::Service::from(li)), + DdnsConfigService::PorkbunV3(pb) => Box::new(porkbun::Service::from(pb)), DdnsConfigService::Selfhost(sh) => Box::new(selfhost::Service::from(sh)), diff --git a/src/services/linode.rs b/src/services/linode.rs new file mode 100644 index 0000000..6b07345 --- /dev/null +++ b/src/services/linode.rs @@ -0,0 +1,288 @@ +use std::net::IpAddr; + +use serde_derive::{Deserialize, Serialize}; + +use crate::http::{Error, Request, Response}; +use crate::util::FixedVec; + +use super::{one_or_more_string, DdnsService, DdnsUpdateError}; + +type RecordId = u64; +type DomainId = u64; + +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)] +pub struct Config { + token: Box, + + #[serde(deserialize_with = "one_or_more_string")] + domains: Vec>, + + /// The time to live expressed in seconds. + /// + /// Values that are not multiples of 300 will be rounded to the nearest + /// multiple by the Linode API. + /// See: https://www.linode.com/docs/api/domains/#domain-record-update__request-body-schema + ttl: u32, +} + +pub struct Service { + config: Config, + cached_records: Vec, +} + +#[derive(Debug, Clone)] +struct Domain { + id: DomainId, + + name: Box, +} + +#[derive(Debug)] +struct Record { + /// Linode uses a master domain (example.com) and encodes different + /// records inside it. + + /// The ID of the record, e.g. ID of sub.example.com + id: RecordId, + + /// The domain associated to the record. + domain_id: DomainId, + + /// The actual name of the record. + name: Box, + + kind: RecordKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum RecordKind { + A, + Aaaa, +} + +impl From for Service { + fn from(config: Config) -> Self { + let mut config = config; + config.token = (String::from("Bearer ") + &config.token).into(); + Self { + config, + cached_records: Vec::new(), + } + } +} + +impl Service { + fn parse_error(&self, response: Response) -> Result<(Box, Box), String> { + let resp_json = response + .into_json::() + .map_err(|e| String::from("unable to parse response as JSON:") + &e.to_string())?; + + let errors = resp_json + .get("errors") + .ok_or_else(|| String::from("expected map"))?; + + let error = errors + .get(0) + .ok_or_else(|| String::from("expected array"))?; + + let field = error + .get("field") + .and_then(|m| m.as_str()) + .unwrap_or("") + .to_owned() + .into_boxed_str(); + + let reason = error + .get("reason") + .and_then(|m| m.as_str()) + .ok_or_else(|| String::from("expected string"))? + .to_owned() + .into_boxed_str(); + + Ok((field, reason)) + } + + fn parse_and_check_response( + &self, + response: Result, + ) -> Result { + let response = match response { + Ok(r) => r + .into_json::() + .map_err(|e| DdnsUpdateError::Json(e.to_string().into()))?, + Err(Error::Status(_, resp)) => { + let (_field, reason) = self.parse_error(resp).map_err(|ref e| { + let error = String::from("unexpected error message structure - "); + DdnsUpdateError::Json((error + e).into_boxed_str()) + })?; + + Err(DdnsUpdateError::Linode(reason.into()))? + } + Err(Error::Transport(tp)) => { + Err(DdnsUpdateError::TransportError(tp.to_string().into()))? + } + }; + + Ok(response) + } + + /// See: + /// - https://www.linode.com/docs/api/domains/#domains-list + /// - https://www.linode.com/docs/api/domains/#domains-list__responses + fn get_domains(&self) -> Result, DdnsUpdateError> { + let response = Request::get("https://api.linode.com/v4/domains") + .set("Content-Type", "application/json") + .set("Authorization", &self.config.token) + .call(); + + let response = self.parse_and_check_response(response)?; + + let results = response.get("data").and_then(|v| v.as_array()); + let Some(domains) = results else { + return Err(DdnsUpdateError::Json("linode returned 0 domains".into())); + }; + + let mut domains_ret = Vec::with_capacity(domains.len()); + + for domain in domains { + let Some(id) = domain.get("id").and_then(|v| v.as_number()) else { + return Err(DdnsUpdateError::Json("domain has no id?".into())); + }; + + let Some(id) = id.as_u64() else { + Err(DdnsUpdateError::Json( + "cannot convert domain ID to u64".into(), + ))? + }; + + let Some(name) = domain.get("domain").and_then(|v| v.as_str()) else { + return Err(DdnsUpdateError::Json("domain has no domain name?".into())); + }; + + domains_ret.push(Domain { + id: DomainId::from(id), + name: name.into(), + }); + } + + Ok(domains_ret) + } + + /// See: + /// - https://www.linode.com/docs/api/domains/#domain-records-list + /// - https://www.linode.com/docs/api/domains/#domain-records-list__responses + fn get_records(&self, domain: Domain) -> Result, DdnsUpdateError> { + let url = format!("https://api.linode.com/v4/domains/{}/records", domain.id); + + let response = Request::get(&url) + .set("Content-Type", "application/json") + .set("Authorization", &self.config.token) + .call(); + + let response = self.parse_and_check_response(response)?; + + let results = response.get("data").and_then(|v| v.as_array()); + let Some(records) = results else { + return Err(DdnsUpdateError::Json("linode returned 0 records".into())); + }; + + let mut returned_records = Vec::new(); + for record in records { + let Some(id) = record.get("id").and_then(|v| v.as_number()) else { + return Err(DdnsUpdateError::Json("record has no id?".into())); + }; + + let Some(id) = id.as_u64() else { + return Err(DdnsUpdateError::Json("id is not a u64 number".into())); + }; + + let Some(name) = record.get("name").and_then(|v| v.as_str()) else { + return Err(DdnsUpdateError::Json("record has no name?".into())); + }; + + // The `name` field contains only the subdomain. + // For example, test.example.com will have its `name` set to "test". + // So we concatenate it to obtain the FQDN. + let fqdn: Box = if name.is_empty() { + domain.name.clone() + } else { + format!("{}.{}", name, domain.name).into() + }; + + let Some(ty) = record.get("type").and_then(|v| v.as_str()) else { + return Err(DdnsUpdateError::Json("record has no type?".into())); + }; + + let kind = match ty { + "A" => RecordKind::A, + "AAAA" => RecordKind::Aaaa, + _ => continue, + }; + + returned_records.push(Record { + id, + domain_id: domain.id, + name: fqdn, + kind, + }); + } + + Ok(returned_records) + } + + /// See: https://www.linode.com/docs/api/domains/#domain-record-update__request-body-schema + fn put_record(&self, record: &Record, ip: IpAddr) -> Result<(), DdnsUpdateError> { + let url = format!( + "https://api.linode.com/v4/domains/{}/records/{}", + record.domain_id, record.id + ); + + // We don't have to include the name again, just the target and TTL. + + let response = Request::put(&url) + .set("Authorization", &self.config.token) + .send_json(serde_json::json!({ + "target": ip.to_string(), + "ttl_sec": self.config.ttl, + })); + + self.parse_and_check_response(response)?; + + Ok(()) + } +} + +impl DdnsService for Service { + fn update_record(&mut self, ips: &[IpAddr]) -> Result, DdnsUpdateError> { + if self.cached_records.is_empty() { + for domain in self.get_domains()? { + for record in self.get_records(domain)? { + if self.config.domains.iter().any(|d| *d == record.name) { + self.cached_records.push(record) + } + } + } + } + + let ipv4 = ips.iter().find(|ip| ip.is_ipv4()); + let ipv6 = ips.iter().find(|ip| ip.is_ipv6()); + + for record in &self.cached_records { + if ipv4.is_some() && record.kind == RecordKind::A { + self.put_record(&record, *ipv4.unwrap())?; + } else if ipv6.is_some() && record.kind == RecordKind::Aaaa { + self.put_record(&record, *ipv6.unwrap())?; + } + } + + let mut result = FixedVec::new(); + if let Some(ipv4) = ipv4 { + result.push(*ipv4); + } + if let Some(ipv6) = ipv6 { + result.push(*ipv6); + } + + Ok(result) + } +} diff --git a/src/services/mod.rs b/src/services/mod.rs index 918fd54..f25150e 100644 --- a/src/services/mod.rs +++ b/src/services/mod.rs @@ -4,6 +4,7 @@ pub mod duckdns; pub mod dummy; pub mod dynu; pub mod ipv64; +pub mod linode; pub mod noip; pub mod porkbun; pub mod selfhost; @@ -38,6 +39,7 @@ pub enum DdnsUpdateError { // used when CF really returned an error #[error("Cloudflare returned error code {0} \"{1}\"")] Cloudflare(u32, Box), + // used when a service says it succeeded, but the returned JSON is nonsense #[error("received erroneous JSON: {0}")] Json(Box), @@ -48,6 +50,9 @@ pub enum DdnsUpdateError { #[error("{0} returned error: {1}")] DynDns(&'static str, Box), + #[error("Linode returned error: {0}")] + Linode(Box), + #[error("Porkbun returned error: {0}")] Porkbun(Box),