Skip to content

Instantly share code, notes, and snippets.

@vi
Last active April 18, 2019 23:15
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save vi/f8a5983e503a84b92d3cc5b845f7ddd9 to your computer and use it in GitHub Desktop.
Save vi/f8a5983e503a84b92d3cc5b845f7ddd9 to your computer and use it in GitHub Desktop.
Simple web portal to update dynamic DNS records
#!/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