Skip to content

Commit

Permalink
services: add Linode service
Browse files Browse the repository at this point in the history
  • Loading branch information
andry-dev committed Apr 14, 2024
1 parent cafb170 commit c165fa7
Show file tree
Hide file tree
Showing 5 changed files with 310 additions and 5 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Currently, the following DDNS providers are supported:
* DuckDNS
* Dynu
* IPv64
* Linode
* NoIP
* Porkbun
* selfHOST.de
Expand Down
16 changes: 12 additions & 4 deletions docs/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "(.*)"

Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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"]
Expand Down
5 changes: 4 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ pub enum IpConfigMethod {

Http {
url: Box<str>,

#[serde(default = "default_regex")]
regex: Box<str>,
},
Expand Down Expand Up @@ -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),
Expand All @@ -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)),
Expand Down
288 changes: 288 additions & 0 deletions src/services/linode.rs
Original file line number Diff line number Diff line change
@@ -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<str>,

#[serde(deserialize_with = "one_or_more_string")]
domains: Vec<Box<str>>,

/// 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<Record>,
}

#[derive(Debug, Clone)]
struct Domain {
id: DomainId,

name: Box<str>,
}

#[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<str>,

kind: RecordKind,
}

#[derive(Debug, Clone, PartialEq, Eq)]
enum RecordKind {
A,
Aaaa,
}

impl From<Config> 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<str>, Box<str>), String> {
let resp_json = response
.into_json::<serde_json::Value>()
.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<Response, Error>,
) -> Result<serde_json::Value, DdnsUpdateError> {
let response = match response {
Ok(r) => r
.into_json::<serde_json::Value>()
.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<Vec<Domain>, 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<Vec<Record>, 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<str> = 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<FixedVec<IpAddr, 2>, 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)
}
}
5 changes: 5 additions & 0 deletions src/services/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -38,6 +39,7 @@ pub enum DdnsUpdateError {
// used when CF really returned an error
#[error("Cloudflare returned error code {0} \"{1}\"")]
Cloudflare(u32, Box<str>),

// used when a service says it succeeded, but the returned JSON is nonsense
#[error("received erroneous JSON: {0}")]
Json(Box<str>),
Expand All @@ -48,6 +50,9 @@ pub enum DdnsUpdateError {
#[error("{0} returned error: {1}")]
DynDns(&'static str, Box<str>),

#[error("Linode returned error: {0}")]
Linode(Box<str>),

#[error("Porkbun returned error: {0}")]
Porkbun(Box<str>),

Expand Down

0 comments on commit c165fa7

Please sign in to comment.