about summary refs log tree commit diff
path: root/users/tazjin/yddns/src/main.rs
blob: 2e2f9fe02fe410b9b31e2a63f34d5bed4d0c089f (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
use anyhow::{anyhow, bail, Context, Result};
use crimp::Request;
use std::env;
use std::net::Ipv4Addr;
use tokio::runtime;
use yandex_cloud::tonic_exports::{Channel, Endpoint, InterceptedService};
use yandex_cloud::yandex::cloud::dns::v1 as dns;
use yandex_cloud::yandex::cloud::dns::v1::dns_zone_service_client::DnsZoneServiceClient;
use yandex_cloud::{AuthInterceptor, TokenProvider};

type DnsClient<T> = DnsZoneServiceClient<InterceptedService<Channel, AuthInterceptor<T>>>;

/// Fetch the current IP from the given URL. It should be the URL of a
/// site that responds only with the IP in plain text, and nothing else.
fn get_current_ip(source: &str) -> Result<Ipv4Addr> {
    let response = Request::get(source)
        .send()
        .context("failed to fetch current IP")?
        .error_for_status(|resp| anyhow!("error response ({})", resp.status))
        .context("received error response for IP")?
        .as_string()?
        .body;

    Ok(response.trim().parse().with_context(|| {
        format!(
            "failed to parse IP address from response body: {}",
            response
        )
    })?)
}

/// Fetch the current address of the target record.
async fn fetch_current_record_addr<T: TokenProvider>(
    client: &mut DnsClient<T>,
    zone_id: &str,
    record_name: &str,
) -> Result<Ipv4Addr> {
    let req = dns::GetDnsZoneRecordSetRequest {
        dns_zone_id: zone_id.into(),
        name: record_name.into(),
        r#type: "A".into(),
    };

    let response = client
        .get_record_set(req)
        .await
        .context("failed to fetch current record set")?
        .into_inner();

    if response.data.len() != 1 {
        bail!(
            "expected exactly one record for 'A {}', but found {}",
            record_name,
            response.data.len()
        );
    }

    Ok(response.data[0]
        .parse()
        .context("failed to parse returned record")?)
}

/// Update the record with the new address, if required.
async fn update_record<T: TokenProvider>(
    client: &mut DnsClient<T>,
    zone_id: &str,
    record_name: &str,
    new_address: Ipv4Addr,
) -> Result<()> {
    let request = dns::UpsertRecordSetsRequest {
        dns_zone_id: zone_id.into(),
        replacements: vec![dns::RecordSet {
            name: record_name.into(),
            r#type: "A".into(),
            ttl: 3600, // 1 hour
            data: vec![new_address.to_string()],
        }],
        ..Default::default()
    };

    client
        .upsert_record_sets(request)
        .await
        .context("failed to update record")?;

    Ok(())
}

/// Compare the record with the expected value, and issue an update if
/// necessary.
async fn compare_update_record<T: TokenProvider>(
    client: &mut DnsClient<T>,
    zone_id: &str,
    record_name: &str,
    new_ip: Ipv4Addr,
) -> Result<()> {
    let old_ip = fetch_current_record_addr(client, zone_id, record_name).await?;

    if old_ip == new_ip {
        println!("IP address unchanged ({})", old_ip);
        return Ok(());
    }

    println!(
        "IP address changed: current record points to {}, but address is {}",
        old_ip, new_ip
    );

    update_record(client, zone_id, record_name, new_ip).await?;
    println!("successfully updated '{}' to 'A {}'", record_name, new_ip);

    Ok(())
}

fn main() -> Result<()> {
    let token =
        env::var("YANDEX_CLOUD_TOKEN").context("Yandex Cloud authentication token unset")?;
    let target_zone_id =
        env::var("TARGET_ZONE").unwrap_or_else(|_| "dnsd0tif5mokfu0mg8i5".to_string());
    let target_record = env::var("TARGET_RECORD").unwrap_or_else(|_| "khtrsk".to_string());

    let current_ip = get_current_ip("http://ifconfig.me")?;
    println!("current IP address is '{}'", current_ip);

    let rt = runtime::Builder::new_current_thread()
        .enable_time()
        .enable_io()
        .build()?;

    rt.block_on(async move {
        let channel = Endpoint::from_static("https://dns.api.cloud.yandex.net")
            .connect()
            .await?;

        let mut client =
            DnsZoneServiceClient::with_interceptor(channel, AuthInterceptor::new(token));

        compare_update_record(&mut client, &target_zone_id, &target_record, current_ip).await
    })?;

    Ok(())
}