Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Linode service #1

Merged
merged 1 commit into from
Apr 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
300 changes: 300 additions & 0 deletions src/services/linode.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
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"))?;

// When Linode returns an error it may signal to us if a field
// in the request is malformed, in which case the key `field` is
// populated in this response.
//
// If no key `field` exists, we revert to an empty string.

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())
})?;

let error_message: Box<str> = if field.is_empty() {
reason.into()
} else {
format!("{} (field = {})", reason, field).into()
};

Err(DdnsUpdateError::Linode(error_message))?
}
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)
}
}
Loading
Loading