Last active
April 18, 2019 23:15
-
-
Save vi/f8a5983e503a84b92d3cc5b845f7ddd9 to your computer and use it in GitHub Desktop.
Simple web portal to update dynamic DNS records
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env run-cargo-script | |
//! ```cargo | |
//! [package] | |
//! name = "dnsmanageweb" | |
//! version = "0.1.0" | |
//! authors = ["Vitaly _Vi Shukela <vi0oss@gmail.com>"] | |
//! edition = "2018" | |
//! | |
//! [dependencies] | |
//! rouille = {version="3.0.0", default-features=false} | |
//! maud = "0.20.0" | |
//! if_chain = "0.1.3" | |
//! maplit = "1.0.1" | |
//! hmac = "0.7.0" | |
//! base64 = "0.10.1" | |
//! sha2 = "0.8.0" | |
//! rand = "0.6.5" | |
//! serde = "1.0.90" | |
//! toml = "0.5.0" | |
//! serde_derive = "1.0.90" | |
//! strfmt = "0.1.6" | |
//! guard = "0.5.0" | |
//! ``` | |
#![feature(proc_macro_hygiene)] | |
#![recursion_limit = "256"] | |
const CONFIG_FILENAME: &str = "config.toml"; | |
use guard::guard; | |
use hmac::crypto_mac::Mac; | |
use if_chain::if_chain; | |
use maplit::hashmap; | |
use maud::{html, Markup, DOCTYPE}; | |
use rouille::input::cookies; | |
use rouille::{post_input, router, try_or_400}; | |
use rouille::{start_server, Request, Response}; | |
use serde_derive::{Deserialize, Serialize}; | |
use std::collections::HashMap; | |
use std::net::Ipv4Addr; | |
use std::sync::Mutex; | |
type HmacSha256 = hmac::Hmac<sha2::Sha256>; | |
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>; | |
#[derive(Serialize, Deserialize)] | |
struct Record { | |
ip: Ipv4Addr, | |
update_key: String, | |
} | |
#[derive(Serialize, Deserialize)] | |
struct User { | |
name: String, | |
password: String, | |
records: HashMap<String, Mutex<Record>>, | |
} | |
#[derive(Serialize, Deserialize)] | |
struct Config { | |
listen_host_post: String, | |
urlbase: String, | |
template: String, | |
user: Vec<User>, | |
} | |
struct DnsManager { | |
data: Config, | |
names: HashMap<String, usize>, | |
key: HmacSha256, | |
} | |
enum Message { | |
Blue(String), | |
Red(String), | |
None, | |
} | |
trait ResponseExt { | |
fn add_security_headers(self) -> Self; | |
} | |
impl ResponseExt for Response { | |
fn add_security_headers(self) -> Self { | |
self.with_additional_header("Cache-control", "no-store") | |
.with_additional_header( | |
"Content-Security-Policy", | |
"default-src 'none'; style-src 'self';", | |
) | |
.with_additional_header("X-XSS-Protection", "1") | |
.with_additional_header("X-Frame-Options", "deny") | |
.with_additional_header("X-Content-Type", "nosniff") | |
} | |
} | |
impl DnsManager { | |
fn new() -> Self { | |
let key: [u8; 32] = rand::random(); | |
let key = HmacSha256::new_varkey(&key).unwrap(); | |
DnsManager { | |
data: Config { | |
listen_host_post: "127.0.0.1:8082".to_string(), | |
urlbase: "/".to_string(), | |
template: r#"curl -v "https://dyn.dns.he.net/nic/update" -d "hostname={record}" -d "password={key}" -d "myip={ip}""#.to_string(), | |
user: vec![], | |
}, | |
names: HashMap::new(), | |
key, | |
} | |
} | |
fn index_users(&mut self) { | |
self.names.clear(); | |
for i in 0..self.data.user.len() { | |
self.names.insert(self.data.user[i].name.clone(), i); | |
} | |
} | |
fn call_update(&self, _uid: usize, record: &str, rd: &Record, ip: Ipv4Addr) -> Result<()> { | |
let ip_str = format!("{}", ip); | |
let cmd = strfmt::strfmt( | |
self.data.template.as_str(), | |
&hashmap! { | |
"record".to_string() => record, | |
"ip".to_string() => ip_str.as_str(), | |
"key".to_string() => rd.update_key.as_str(), | |
}, | |
)?; | |
println!("cmd: {}", cmd); | |
let status = std::process::Command::new("sh") | |
.arg("-c") | |
.arg(cmd) | |
.status()?; | |
if status.success() { | |
Ok(()) | |
} else { | |
return Err("Error status")?; | |
} | |
} | |
fn write_to_file(&self) -> Result<()> { | |
use std::io::Write; | |
let tmpfn = format!("{}2", CONFIG_FILENAME); | |
let mut f = std::fs::File::create(&tmpfn)?; | |
let u = toml::to_string_pretty(&self.data)?; | |
writeln!(f, "{}", u)?; | |
std::fs::rename(tmpfn, CONFIG_FILENAME)?; | |
Ok(()) | |
} | |
fn read_from_file(&mut self) -> Result<()> { | |
use std::io::Read; | |
let mut f = std::fs::File::open(CONFIG_FILENAME)?; | |
let mut v = Vec::with_capacity(2048); | |
f.read_to_end(&mut v)?; | |
let u = toml::from_slice(&v[..])?; | |
self.data = u; | |
Ok(()) | |
} | |
fn render_head(&self) -> Markup { | |
html! { | |
head { | |
title { "DNS manager" } | |
meta name="viewport" content="width=device-width, initial-scale=1"; | |
link rel="stylesheet" href={(self.data.urlbase) "style.css"}; | |
} | |
} | |
} | |
fn render_header(&self, user: Option<usize>, msg: Message) -> Markup { | |
html! { | |
div { "DNS manager (educational example)\n\n" } | |
@if let Some(uid) = user { | |
div { | |
"Logged in as " | |
(self.data.user[uid].name) | |
" " | |
form method="post" action={(self.data.urlbase) "logout"} { | |
button { "Logout" } | |
} | |
"\n\n" | |
} | |
} @else { | |
div { "Not logged in.\n\n" } | |
} | |
@match msg { | |
Message::None => div { "\n\n" }, | |
Message::Blue(s) => div .blue { (s) "\n\n" }, | |
Message::Red(s) => div .red { (s) "\n\n" }, | |
} | |
} | |
} | |
fn render_main(&self, user: Option<usize>, msg: Message) -> Response { | |
Response::html(html! { | |
(DOCTYPE) | |
html { | |
(self.render_head()) | |
body { | |
(self.render_header(user, msg)) | |
@if let Some(uid) = user { | |
div { "Your \"domains\":\n" } | |
ul { | |
@for r in self.data.user[uid].records.keys() { | |
li { | |
a href=( | |
format!("{}records/{}",self.data.urlbase, r) | |
) { (r) } | |
} | |
} | |
} | |
} @else { | |
form method="post" action={(self.data.urlbase) "login"} { | |
"Username: "; input name="user" type="text" ; br; | |
"Password: "; input name="password" type="password" ; br; | |
button { "Login" } br; | |
} | |
} | |
} | |
} | |
}) | |
.add_security_headers() | |
} | |
fn render_record(&self, uid: usize, record: &str, msg: Message) -> Response { | |
Response::html(html! { | |
(DOCTYPE) | |
html { | |
(self.render_head()) | |
body { | |
(self.render_header(Some(uid), msg)) | |
div { | |
a href={(self.data.urlbase)} { "Goto home" } | |
"\n\n" | |
} | |
div { " Record: " (record) } | |
div { " Type: A (IPv4)"} | |
form method="post" action="?edit" { | |
div { | |
"Address: " | |
input name="address" type="text" value=( | |
self.data.user[uid].records[record].lock().unwrap().ip | |
); | |
} | |
br; | |
button { "Edit address" } | |
} | |
} | |
} | |
}) | |
.add_security_headers() | |
} | |
fn serve_update_record(&self, rq: &Request, user: Option<usize>, record: String) -> Response { | |
guard!( | |
let Some(uid) = user else { | |
return Response::redirect_303(self.data.urlbase.clone()); | |
}); | |
let err = |msg: String| self.render_main(Some(uid), Message::Red(msg)); | |
guard!( | |
let Some(r) = self.data.user[uid].records.get(&record) else { | |
return err("Record not found".to_string()) | |
}); | |
let input = try_or_400!(post_input!(rq, { | |
address: String, | |
})); | |
guard!( | |
let Ok(newaddr) = input.address.parse() else { | |
return err("Failed to parse the address".to_string()) | |
}); | |
let mut rr = r.lock().unwrap(); | |
if let Err(e) = self.call_update(uid, record.as_str(), &*rr, newaddr) { | |
return err(format!("Error updating: {}", e)); | |
} | |
rr.ip = newaddr; | |
drop(rr); | |
if let Err(e) = self.write_to_file() { | |
return err(format!("Failed to save new config: {}", e)); | |
} | |
self.render_record( | |
uid, | |
record.as_str(), | |
Message::Blue("Address updated".to_string()), | |
) | |
} | |
fn serve_login(&self, rq: &Request, mut user: Option<usize>) -> Response { | |
let input = try_or_400!(post_input!(rq, { | |
user: String, | |
password: String, | |
})); | |
if_chain! { | |
if let Some(uid) = self.names.get(&input.user); | |
if self.data.user[*uid].password == input.password; | |
then { | |
user = Some(*uid); | |
let mut mac = self.key.clone(); | |
mac.input(format!("{}", uid).as_bytes()); | |
let mac = mac.result().code(); | |
let mac = base64::encode(mac.as_slice()); | |
self.render_main( | |
user, | |
Message::Blue("Logged in successfully".to_string()), | |
).with_additional_header( | |
"Set-Cookie", | |
format!("user={}:{}; HttpOnly; SameSite=Strict", uid, mac), | |
) | |
} else { | |
self.render_main( | |
user, | |
Message::Red("Incorrect username or password".to_string()), | |
).with_status_code(400) | |
} | |
} | |
} | |
fn serve(&self, rq: &Request) -> Response { | |
//eprintln!("{:?}", rq); | |
println!("{} {}", rq.method(), rq.url()); | |
let mut user: Option<usize> = if_chain! { | |
if let Some((_, val)) = cookies(&rq).find(|&(n, _)| n == "user"); | |
let uh = val.split(':').collect::<Vec<_>>(); | |
if uh.len() == 2; | |
if let Ok(uid) = uh[0].parse::<usize>(); | |
if uid < self.data.user.len(); | |
let mut mac = self.key.clone(); | |
let _ = mac.input(format!("{}", uid).as_bytes()); | |
let mac = base64::encode(mac.result().code().as_slice()); | |
if uh[1] == mac; | |
then { | |
Some(uid) | |
} else { | |
None | |
} | |
}; | |
router!(rq, | |
(GET) ["/"] => { | |
self.render_main(user, Message::None) | |
}, | |
(GET) ["/records/{record}", record: String] => { | |
if let Some(uid) = user { | |
if self.data.user[uid].records.contains_key(&record) { | |
self.render_record(uid, &record, Message::None) | |
} else { | |
self.render_main( | |
Some(uid), | |
Message::Red("Record not found".to_string()), | |
).with_status_code(404) | |
} | |
} else { | |
Response::redirect_303(self.data.urlbase.clone()) | |
} | |
}, | |
(POST) ["/records/{record}", record: String] => { | |
self.serve_update_record(rq, user, record) | |
}, | |
(POST) ["/login"] => { | |
self.serve_login(rq, user) | |
}, | |
(POST) ["/logout"] => { | |
user = None; | |
self.render_main( | |
user, | |
Message::Blue("Logged out successfully".to_string()), | |
).with_additional_header( | |
"Set-Cookie", | |
"user=\"\"; HttpOnly; SameSite=Strict; Max-Age=0;", | |
) | |
}, | |
(GET) ["/style.css"] => { | |
Response::from_data("text/css", br#" | |
body { | |
font-family: monospace; | |
font-size: 16pt; | |
} | |
div { | |
white-space: pre; | |
} | |
.red { | |
color: red; | |
} | |
.blue { | |
color: blue; | |
} | |
form { | |
display: inline; | |
} | |
"#.to_vec()).with_additional_header("Cache-Control", "public; max-age=300") | |
}, | |
_ => Response::text("Something not found").with_status_code(404), | |
) | |
} | |
fn fill_in_with_fake_data(&mut self) { | |
self.data.user.push(User { | |
name: "foo".to_string(), | |
password: "bar".to_string(), | |
records: hashmap! { | |
"foo.bar.com".to_string() => Mutex::new(Record{ | |
ip: Ipv4Addr::UNSPECIFIED, | |
update_key: "qqq".to_string(), | |
}), | |
"lol.bar.com".to_string() => Mutex::new(Record{ | |
ip: Ipv4Addr::LOCALHOST, | |
update_key: "www".to_string(), | |
}), | |
}, | |
}); | |
self.data.user.push(User { | |
name: "lol".to_string(), | |
password: "qqq".to_string(), | |
records: hashmap! { | |
"qqq.bar.com".to_string() => Mutex::new(Record{ | |
ip: Ipv4Addr::LOCALHOST, | |
update_key: "rrr".to_string(), | |
}), | |
}, | |
}); | |
} | |
} | |
fn main() -> Result<()> { | |
let mut dnsmanager = DnsManager::new(); | |
if !std::path::Path::new(CONFIG_FILENAME).exists() { | |
println!("Writing sample config file {}", CONFIG_FILENAME); | |
dnsmanager.fill_in_with_fake_data(); | |
dnsmanager.write_to_file()?; | |
} | |
dnsmanager.read_from_file()?; | |
dnsmanager.index_users(); | |
println!("Listening on {}", dnsmanager.data.listen_host_post); | |
start_server(dnsmanager.data.listen_host_post.clone(), move |rq| { | |
dnsmanager.serve(rq) | |
}) | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment