Skip to content

Commit

Permalink
feat: Add a REST API rust backend based on Axum and OpenAPI
Browse files Browse the repository at this point in the history
Use Axum to serve a REST API based on the statistics collected
in the database.
Use http-tower::ServeDir to serve static assets (the future web
app) in the debug profile.
Use rust_embed to serve the assets in release profile.
Use Aide to generate an OpenAPI schema based on the Axum routes.
This schema will then be used in the web app to parse and validate the
REST API types. This allows declaring types in Rust, and then using
them in typescript using the OpenAPI schema as the in-between
converter.
  • Loading branch information
alcroito committed Feb 28, 2023
1 parent 71dac58 commit 7f8384e
Show file tree
Hide file tree
Showing 25 changed files with 2,084 additions and 56 deletions.
1,037 changes: 995 additions & 42 deletions Cargo.lock

Large diffs are not rendered by default.

13 changes: 13 additions & 0 deletions config/do_ddns.sample.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,19 @@ dry_run = "false"
# macOS: /Users/<user>/Library/Application Support/org.alcroito.digitalocean-dyndns/dyndns_db.sqlite
# database_path = "/tmp/dyndns_stats_db.sqlite"

# Enable web server to visualize collected statistics.
# Disabled by default.
# enable_web = "true"

# An IPv4 / IPv6 address or host name where to serve HTTP pages on.
# In case of host that has a dual IP stack, both will be used.
# Default is localhost.
# listen_hostname = "true"

# Port number where to serve HTTP pages on.
# Default is 8095.
# listen_port = "8095"

## Simple config mode sample

# Updates the IP of the 'home.mysite.com' A record.
Expand Down
35 changes: 34 additions & 1 deletion crates/dyndns/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,38 @@ path = "src/lib.rs"
[features]
default = []
stats = ["diesel", "diesel_migrations", "libsqlite3-sys"]
debug_static_embedded = ["web", "rust-embed/debug-embed"]
web = [
"stats",
"aide",
"axum",
"axum-jsonschema",
"axum-macros",
"http",
"hyper",
"mime_guess",
"rust-embed",
"schemars",
"tower",
"tower-http",
"tokio"
]

[dependencies]
chrono = { version = "0.4", default-features = false, features = ["alloc", "serde", "clock"] }
clap = { version = "4", features = ["cargo"] }
color-eyre = "0.6"
humantime = "2"
itertools = "0.10"
native-tls = { version = "0.2", features = ["vendored"] }
once_cell = "1"
reqwest = { version = "0.11", features = ["blocking", "json"] }
secrecy = "0.8"
serde = { version = "1", features = ["derive"] }
serde = { version = "1", features = ["derive", "rc"] }
serde_json = "1"
serde_with = "2"
signal-hook = { version = "0.3", features = ["extended-siginfo"] }
tailsome = "0.1"
toml = "0.7"
tracing = "0.1"
tracing-log = "0.1"
Expand All @@ -47,6 +66,20 @@ diesel_migrations = { version = "2", features = ["sqlite"], optional = true }
directories = "4"
libsqlite3-sys = { version = "0.25", features = ["bundled"], optional = true }

# Web server dependencies
aide = {version = "0.10", optional = true, features = ["redoc", "axum", "axum-extra", "macros"]}
axum = {version = "0.6", optional = true }
axum-jsonschema = { version = "0.5", optional = true, features = ["aide"]}
axum-macros = {version = "0.3", optional = true }
http = {version = "0.2", optional = true }
hyper = { version = "0.14", optional = true }
mime_guess = { version = "2", optional = true }
rust-embed = { version = "6", features = ["debug-embed"], optional = true }
schemars = { version = "0.8", optional = true, features = ["chrono"] }
tower = { version = "0.4", features = ["full"], optional = true }
tower-http = { version = "0.4", features = ["full"], optional = true }
tokio = { version = "1", features = ["full"], optional = true }

[build-dependencies]
# Keep anyhow, because vergen depends on it.
anyhow = "1"
Expand Down
147 changes: 147 additions & 0 deletions crates/dyndns/generated/openapi.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
{
"openapi": "3.1.0",
"info": {
"title": "ddns Open API",
"summary": "ddns Open API",
"description": "ddns Open API",
"version": ""
},
"paths": {
"/api/v1/domain_record_ip_changes": {
"get": {
"description": "List all recent domain record ip changes",
"responses": {
"default": {
"description": "",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object",
"required": [
"GenericError"
],
"properties": {
"GenericError": {
"type": "string"
}
}
}
]
},
"example": {
"GenericError": "generic error"
}
}
}
},
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DomainRecordIpChanges"
}
}
}
}
}
}
},
"/docs/": {
"get": {
"description": "This documentation page.",
"responses": {
"default": {
"description": "",
"content": {
"application/json": {
"schema": {
"oneOf": [
{
"type": "object",
"required": [
"GenericError"
],
"properties": {
"GenericError": {
"type": "string"
}
}
}
]
},
"example": {
"GenericError": "generic error"
}
}
}
},
"200": {
"description": "HTML content",
"content": {
"text/html": {
"schema": {
"type": "string"
}
}
}
}
}
}
}
},
"components": {
"schemas": {
"DomainRecordIpChange": {
"type": "object",
"required": [
"attempt_date",
"domain_record_id",
"id",
"name",
"set_ip",
"success"
],
"properties": {
"attempt_date": {
"type": "string",
"format": "partial-date-time"
},
"domain_record_id": {
"type": "integer",
"format": "int64"
},
"id": {
"type": "integer",
"format": "int64"
},
"name": {
"type": "string"
},
"set_ip": {
"type": "string"
},
"success": {
"type": "boolean"
}
}
},
"DomainRecordIpChanges": {
"type": "object",
"required": [
"changes"
],
"properties": {
"changes": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DomainRecordIpChange"
}
}
}
}
}
}
}
34 changes: 34 additions & 0 deletions crates/dyndns/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -222,5 +222,39 @@ Env var: DO_DYNDNS_DATABASE_PATH=/tmp/dyndns_stats_db.sqlite",
}
command = command.arg(arg);

// Don't show web related options when building with the feature disabled.
let mut arg = Arg::new(ENABLE_WEB)
.long("enable-web")
.action(clap::ArgAction::SetTrue)
.help(
"\
Enable web server to visualize collected statistics.
Env var: DO_DYNDNS_ENABLE_WEB=true",
);
if cfg!(not(feature = "web")) {
arg = arg.hide(true);
}
command = command.arg(arg);

let mut arg = Arg::new(LISTEN_HOSTNAME).long("listen-hostname").help(
"\
An IP address or host name where to serve HTTP pages on.
Env var: DO_DYNDNS_LISTEN_HOSTNAME=192.168.0.1",
);
if cfg!(not(feature = "web")) {
arg = arg.hide(true);
}
command = command.arg(arg);

let mut arg = Arg::new(LISTEN_PORT).long("listen-port").help(
"\
Port numbere where to serve HTTP pages on.
Env var: DO_DYNDNS_LISTEN_PORT=8080",
);
if cfg!(not(feature = "web")) {
arg = arg.hide(true);
}
command = command.arg(arg);

command
}
3 changes: 3 additions & 0 deletions crates/dyndns/src/config/app_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ pub struct GeneralOptions {
pub ipv6: bool,
pub collect_stats: bool,
pub db_path: Option<std::path::PathBuf>,
pub enable_web: bool,
pub listen_hostname: String,
pub listen_port: u16,
}

#[non_exhaustive]
Expand Down
12 changes: 11 additions & 1 deletion crates/dyndns/src/config/app_config_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -358,11 +358,18 @@ impl<'clap> AppConfigBuilder<'clap> {
.ok()
.map(|db_path| std::path::PathBuf::from(&db_path));

let enable_web = ValueBuilder::new(ENABLE_WEB)
.with_env_var_name()
.with_clap_bool(self.clap_matches)
.with_config_value(self.toml_table.as_ref())
.with_default(false)
.build()?;

let listen_hostname: String = ValueBuilder::new(LISTEN_HOSTNAME)
.with_env_var_name()
.with_clap(self.clap_matches)
.with_config_value(self.toml_table.as_ref())
.with_default("0.0.0.0".to_owned())
.with_default("localhost".to_owned())
.build()?;

let listen_port = ValueBuilder::new(LISTEN_PORT)
Expand All @@ -381,6 +388,9 @@ impl<'clap> AppConfigBuilder<'clap> {
ipv6,
collect_stats,
db_path,
enable_web,
listen_hostname,
listen_port,
};
Ok(general_options)
}
Expand Down
3 changes: 3 additions & 0 deletions crates/dyndns/src/config/consts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ pub static IPV4_SUPPORT: &str = "ipv4";
pub static IPV6_SUPPORT: &str = "ipv6";
pub static COLLECT_STATS: &str = "collect_stats";
pub static DB_PATH: &str = "database_path";
pub static ENABLE_WEB: &str = "enable_web";
pub static LISTEN_HOSTNAME: &str = "listen_hostname";
pub static LISTEN_PORT: &str = "listen_port";
pub static LOG_LEVEL_VERBOSITY_SHORT: &str = "v";
pub static LOG_LEVEL_VERBOSITY_SHORT_CHAR: char = 'v';

Expand Down
8 changes: 6 additions & 2 deletions crates/dyndns/src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ use crate::domain_record_api::digital_ocean::DigitalOceanApi;
use crate::global_state::GlobalState;
use crate::logger::setup_logger;
use crate::signal_handlers::{setup_forceful_term_signal_handling, AppTerminationHandler};

use crate::updater::Updater;
use color_eyre::eyre::Result;

#[cfg(feature = "web")]
use crate::web::server::start_web_server_and_wait;

pub fn start_daemon(global_state: GlobalState) -> Result<()> {
setup_logger(&global_state.config.general_options.log_level)?;
setup_forceful_term_signal_handling()?;
Expand All @@ -15,8 +17,10 @@ pub fn start_daemon(global_state: GlobalState) -> Result<()> {
let term_handler = AppTerminationHandler::new()?;
term_handler.setup_exit_panic_hook();

let updater = Updater::new(global_state, do_api, term_handler.clone());
#[cfg(feature = "web")]
start_web_server_and_wait(term_handler.clone(), &global_state.config);

let updater = Updater::new(global_state, do_api, term_handler.clone());
let updater_thread_handle = updater.start_update_loop_detached();
term_handler.set_updater_thread(updater_thread_handle);
term_handler.handle_term_signals_gracefully()?;
Expand Down
46 changes: 46 additions & 0 deletions crates/dyndns/src/db/crud/domain_record_ip_changes.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
use crate::db::types::*;
use color_eyre::eyre::Result;
use diesel::prelude::*;
use diesel::sqlite::SqliteConnection;

use diesel::sql_types::Text;
use schemars::JsonSchema;
use serde::Serialize;

#[derive(Queryable, Debug, Serialize, QueryableByName, JsonSchema)]
pub struct DomainRecordIpChange {
#[diesel(embed)]
#[serde(flatten)]
pub domain_record_update: DomainRecordUpdate,
#[diesel(sql_type = Text)]
pub name: String,
}

#[derive(Serialize, JsonSchema)]
pub struct DomainRecordIpChanges {
pub changes: Vec<DomainRecordIpChange>,
}

pub fn get_domain_record_ip_changes(conn: &mut SqliteConnection) -> Result<DomainRecordIpChanges> {
// For each domain row, get previous ip and only return results
// where the ip has changed.
let changes = diesel::sql_query(
"
SELECT t.* FROM
(SELECT u.*, r.*,
lag(u.set_ip) over (
partition by domain_record_id
order by u.attempt_date ASC
) as prev_set_ip
FROM domain_record_updates u
INNER JOIN domain_records r
ON u.domain_record_id=r.id
WHERE u.success = true
ORDER BY u.attempt_date DESC
) t
WHERE prev_set_ip IS NULL OR prev_set_ip != set_ip",
)
.load::<DomainRecordIpChange>(conn)?;
let results = DomainRecordIpChanges { changes };
Ok(results)
}
Loading

0 comments on commit 7f8384e

Please sign in to comment.