feat: initial commit

This commit is contained in:
transcaffeine 2024-09-08 14:24:40 +02:00
commit 436dbe1393
Signed by: transcaffeine
GPG Key ID: 03624C433676E465
22 changed files with 3644 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
config.yaml
/target

3073
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

23
Cargo.toml Normal file
View File

@ -0,0 +1,23 @@
[package]
name = "pdns-tsig-key-manager"
version = "0.0.1"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
askama = "0.12.1"
axum = { version = "0.7.5", features = ["tracing", "multipart", "macros", "http2"] }
axum-core = { version = "0.4.3", features = ["tracing"] }
axum-extra = { version = "0.9.3", features = ["form"] }
config = "0.14.0"
http = "1.1.0"
openidconnect = "3.5.0"
reqwest = { version = "0.12.7", features = ["json"] }
serde = { version = "1.0.209", features = ["derive"] }
serde_json = "1.0.128"
strum = { version = "0.26.3", features = ["derive"] }
strum_macros = "0.26.4"
time = { version = "0.3.36", features = ["local-offset", "parsing", "serde", "serde-human-readable"] }
tokio = { version = "1.40.0", features = ["full"] }
url = { version = "2.5.2", features = ["serde"] }

13
config.example.yml Normal file
View File

@ -0,0 +1,13 @@
---
server:
bind_address: 127.0.0.1
port: 3000
powerdns:
server_url: https://my.powerdns.api.server:3000
server_api_key: my_power_dns_server_api_key
server_id: localhost
oidc:
issuer: https://openidconnect.provider.tld/realms/if-you-use-keycloak
client_id: client-id-you-generated
client_secret: client-secret-that-you-generated-or-copied
callback: https://the-public-domain-of-this.service.tld/openid/callback

46
src/config.rs Normal file
View File

@ -0,0 +1,46 @@
use std::net::IpAddr;
use url::Url;
use serde::Deserialize;
use config::{
Config,
ConfigError,
File,
};
#[derive(Debug, Deserialize, Clone)]
pub struct ServerConfig {
pub bind_address: IpAddr,
pub port: u16,
}
#[derive(Debug, Deserialize, Clone)]
pub struct PowerDnsConfig {
pub server_url: Url,
pub server_api_key: String,
pub server_id: String,
}
#[derive(Debug, Deserialize, Clone)]
pub struct OidcConfig {
pub issuer: Url,
pub client_id: String,
pub client_secret: String,
pub callback: Url,
}
#[derive(Debug, Deserialize, Clone)]
pub struct PowerDnsTsigKeyManagerConfig {
pub oidc: OidcConfig,
pub powerdns: PowerDnsConfig,
pub server: ServerConfig,
}
impl PowerDnsTsigKeyManagerConfig {
pub fn load(filename: &str) -> Result<Self, ConfigError> {
Config::builder()
.add_source(File::with_name(filename))
.build()?
.try_deserialize()
}
}

1
src/error/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod powerdns;

70
src/error/powerdns.rs Normal file
View File

@ -0,0 +1,70 @@
use std::vec::Vec;
use std::fmt::Display;
use serde::{
Deserialize,
Serialize,
};
use axum::response::IntoResponse;
use reqwest::StatusCode;
#[derive(Debug, Serialize, Deserialize)]
pub struct PowerDnsApiError {
pub error: String,
pub errors: Option<Vec<String>>,
}
#[derive(Debug)]
pub enum PowerDnsError {
Reqwest(reqwest::Error),
Serde(serde_json::Error),
Api(PowerDnsApiError),
}
impl Display for PowerDnsError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PowerDnsError::Reqwest(r) => f.write_fmt(format_args!("Request error on {:?}: {:?}", r.url(), r)),
PowerDnsError::Serde(s) => f.write_fmt(format_args!("SerDe error: {:?}", s)),
PowerDnsError::Api(a) => f.write_fmt(format_args!("API error: {:?}", a)),
}
}
}
impl std::error::Error for PowerDnsError {}
impl From<reqwest::Error> for PowerDnsError {
fn from(e: reqwest::Error) -> Self {
Self::Reqwest(e)
}
}
impl From<serde_json::Error> for PowerDnsError {
fn from(e: serde_json::Error) -> Self {
Self::Serde(e)
}
}
impl From<PowerDnsApiError> for PowerDnsError {
fn from(e: PowerDnsApiError) -> Self {
Self::Api(e)
}
}
pub type PowerDnsResult<T> = Result<T, PowerDnsError>;
impl IntoResponse for PowerDnsError {
fn into_response(self) -> axum::response::Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Error processing request: {}", self),
).into_response()
}
}
impl From<reqwest::Error> for PowerDnsApiError {
fn from(err: reqwest::Error) -> Self {
Self {
error: format!("{:?}", err),
errors: None,
}
}
}

37
src/handlers/api.rs Normal file
View File

@ -0,0 +1,37 @@
use axum::extract::{
Path,
State,
};
use axum::response::{
IntoResponse,
Json,
};
use crate::AppState;
use crate::model::tsigkey::{
TsigKey,
};
pub async fn list(State(app_state): State<AppState>) -> impl IntoResponse {
app_state.pdns_client.list_tsig_keys()
.await
.and_then(|keys| Ok(Json(keys)))
}
pub async fn get(State(app_state): State<AppState>, Path(tsig_key_name): Path<String>) -> impl IntoResponse {
app_state.pdns_client.get_tsig_key(tsig_key_name)
.await
.and_then(|key| Ok(Json(key)))
}
pub async fn add(State(app_state): State<AppState>, Json(tsig_key): Json<TsigKey>) -> impl IntoResponse {
app_state.pdns_client.add_tsig_key(tsig_key)
.await
.and_then(|key| Ok(Json(key)))
}
pub async fn delete(State(app_state): State<AppState>, Path(tsig_key_name): Path<String>) -> impl IntoResponse {
app_state.pdns_client.delete_tsig_key(tsig_key_name)
.await
.and_then(|_| Ok(Json(())))
}

12
src/handlers/app.rs Normal file
View File

@ -0,0 +1,12 @@
use askama::Template;
use axum::response::IntoResponse;
use crate::util::askama::HtmlTemplate;
#[derive(Template)]
#[template(path = "app_home.html")]
struct AppHomeTemplate {}
pub async fn home() -> impl IntoResponse {
HtmlTemplate(AppHomeTemplate {})
}

4
src/handlers/mod.rs Normal file
View File

@ -0,0 +1,4 @@
pub(crate) mod api;
pub(crate) mod app;
pub(crate) mod user;
pub(crate) mod openid;

65
src/handlers/openid.rs Normal file
View File

@ -0,0 +1,65 @@
use axum::response::{
IntoResponse,
Redirect,
};
use axum::extract::{
State,
Query,
};
use openidconnect::{
AuthenticationFlow,
AuthorizationCode,
CsrfToken,
Nonce,
OAuth2TokenResponse,
Scope,
};
use openidconnect::core::CoreResponseType;
use openidconnect::reqwest::async_http_client;
use serde::Deserialize;
use crate::AppState;
#[derive(Deserialize)]
pub struct OidcCallback {
code: String,
state: String
}
pub async fn login(State(app_state): State<AppState>) -> impl IntoResponse {
let (auth_url, csrf_state, nonce) = app_state.oidc_client.authorize_url(
AuthenticationFlow::<CoreResponseType>::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
)
.add_scope(Scope::new("openid".to_string()))
.add_scope(Scope::new("profile".to_string()))
.url();
let mut lock = app_state.state.lock().await;
lock.insert(
nonce.secret().to_owned(),
csrf_state.secret().to_owned(),
);
Redirect::to(auth_url.as_str())
}
pub async fn callback(
State(app_state): State<AppState>,
Query(callback): Query<OidcCallback>,
) -> impl IntoResponse {
println!("Received auth code: {}", callback.code.clone());
let token_response = app_state.oidc_client
.exchange_code(AuthorizationCode::new(callback.code.to_owned()))
.request_async(async_http_client)
.await
.unwrap();
println!("Exchanged for access token: {}", token_response.access_token().secret());
println!("\tvalid for scopes {:?}", token_response.scopes());
Redirect::to("/user/home")
// let id_token_verifier: CoreIdTokenVerifier = app_state.oidc_client.id_token_verifier();
// let id_token_claims: &CoreIdTokenClaims = token_response
// .extra_fields()
// .id_token()
// .expect("IdP did not return an ID token")
// .claims(&id_token_verifier, &nonce)
}

12
src/handlers/user.rs Normal file
View File

@ -0,0 +1,12 @@
use askama::Template;
use axum::response::IntoResponse;
use crate::util::askama::HtmlTemplate;
#[derive(Template)]
#[template(path = "user_home.html")]
struct UserHomeTemplate {}
pub async fn home() -> impl IntoResponse {
HtmlTemplate(UserHomeTemplate {})
}

61
src/main.rs Normal file
View File

@ -0,0 +1,61 @@
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::{Arc};
use tokio::sync::Mutex;
use axum::Router;
use axum::routing::{get, post};
use openidconnect::core::CoreClient;
pub mod model;
mod config;
mod error;
mod handlers;
mod util;
use crate::util::powerdns::PowerDnsApi;
#[derive(Clone)]
pub struct AppState {
oidc_client: CoreClient,
pdns_client: PowerDnsApi,
state: Arc<Mutex<HashMap<String, String>>>,
}
#[tokio::main]
async fn main() {
let config = config::PowerDnsTsigKeyManagerConfig::load("config.yaml")
.expect("Unable to load and parse config.yaml");
let oidc_client = util::openid::create_client(
config.oidc.issuer,
config.oidc.client_id,
config.oidc.client_secret,
config.oidc.callback,
).await;
let pdns_client = util::powerdns::PowerDnsApi::new(
config.powerdns.server_url,
config.powerdns.server_api_key,
config.powerdns.server_id,
).await;
let state: HashMap<String, String> = HashMap::new();
let app_state = AppState {
oidc_client,
pdns_client,
state: Arc::new(Mutex::new(state)),
};
let app = Router::new()
.route("/user/home", get(handlers::user::home))
.route("/openid/login", get(handlers::openid::login))
.route("/openid/callback", get(handlers::openid::callback))
.route("/api/tsigkey/list", get(handlers::api::list))
.route("/api/tsigkey/:tsig_key_name", get(handlers::api::get).delete(handlers::api::delete))
.route("/api/tsigkey/", post(handlers::api::add).put(handlers::api::add))
.route("/", get(handlers::app::home))
.with_state(app_state);
let socket_addr: SocketAddr = (config.server.bind_address, config.server.port).into();
let listener = tokio::net::TcpListener::bind(socket_addr).await.unwrap();
axum::serve::serve(listener, app).await.unwrap();
}

1
src/model/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod tsigkey;

29
src/model/tsigkey.rs Normal file
View File

@ -0,0 +1,29 @@
use serde::{
Serialize,
Deserialize,
};
use strum_macros::{
EnumString,
};
#[derive(Debug, Clone, EnumString, Serialize, Deserialize)]
#[strum(ascii_case_insensitive, serialize_all = "kebab-case")]
pub enum TsigKeyAlgorithm {
#[serde(rename = "hmac-md5")]
#[strum(to_string = "hmac-md5")]
HmacMd5,
#[serde(rename = "hmac-sha256")]
#[strum(to_string = "hmac-sha256")]
HmacSha256,
#[serde(rename = "hmac-sha512")]
#[strum(to_string = "hmac-sha512")]
HmacSha512,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TsigKey {
pub algorithm: TsigKeyAlgorithm,
pub name: String,
#[serde(rename = "key")]
pub secret: Option<String>,
}

20
src/util/askama.rs Normal file
View File

@ -0,0 +1,20 @@
use askama::Template;
use axum::response::{Html,Response};
use axum::{
http::StatusCode,
response::IntoResponse,
};
pub struct HtmlTemplate<T>(pub T);
impl<T> IntoResponse for HtmlTemplate<T>
where
T: Template,
{
fn into_response(self) -> Response {
match self.0.render() {
Ok(html) => Html(html).into_response(),
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to render html template: {err}")).into_response(),
}
}
}

3
src/util/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub(crate) mod askama;
pub(crate) mod openid;
pub(crate) mod powerdns;

28
src/util/openid.rs Normal file
View File

@ -0,0 +1,28 @@
use openidconnect::reqwest::async_http_client;
use openidconnect::core::{
CoreClient,
CoreProviderMetadata,
};
use openidconnect::{
ClientId,
ClientSecret,
IssuerUrl,
RedirectUrl,
};
use url::Url;
pub async fn create_client(issuer: Url, id: String, secret: String, redirect_url: Url) -> CoreClient {
let issuer_url = IssuerUrl::from_url(issuer);
let metadata = CoreProviderMetadata::discover_async(issuer_url, async_http_client)
.await
.unwrap_or_else(|err| {
println!("Unable to discover provider metadata: {}", err);
unreachable!();
});
CoreClient::from_provider_metadata(
metadata,
ClientId::new(id),
Some(ClientSecret::new(secret)),
)
.set_redirect_uri(RedirectUrl::from_url(redirect_url))
}

109
src/util/powerdns.rs Normal file
View File

@ -0,0 +1,109 @@
use std::vec::Vec;
use url::Url;
use serde::{
de::DeserializeOwned,
};
use reqwest::{
Client,
Method,
RequestBuilder,
StatusCode,
Response,
};
use crate::model::tsigkey::TsigKey;
use crate::error::powerdns::{
PowerDnsError,
PowerDnsResult,
PowerDnsApiError,
};
#[derive(Clone)]
pub struct PowerDnsApi {
api_url: Url,
api_key: String,
server_id: String,
http_client: Client
}
// some comment
impl PowerDnsApi {
pub async fn new(api_url: Url, api_key: String, server_id: String) -> Self {
PowerDnsApi {
api_url,
api_key,
server_id,
http_client: Client::new(),
}
}
pub async fn list_tsig_keys(self) -> PowerDnsResult<Vec<TsigKey>> {
Self::transform_response::<Vec<TsigKey>>(
self.get_request_for(Method::GET, "tsigkeys".to_string()).send().await?,
StatusCode::OK,
).await
}
pub async fn get_tsig_key(self, name: String) -> PowerDnsResult<TsigKey> {
Self::transform_response::<TsigKey>(
self.get_request_for(Method::GET, format!("tsigkeys/{}", name)).send().await?,
StatusCode::OK,
).await
}
pub async fn add_tsig_key(self, key: TsigKey) -> PowerDnsResult<TsigKey> {
match key.secret {
Some(ref _secret) => Self::transform_response::<TsigKey>(
self.get_request_for(
Method::PUT,
format!("tsigkeys/{}", key.name),
).json(&key).send().await?,
StatusCode::OK,
).await,
None => Self::transform_response::<TsigKey>(
self.get_request_for(Method::POST, "tsigkeys".to_string())
.json(&key).send().await?,
StatusCode::CREATED,
).await,
}
}
pub async fn delete_tsig_key(self, name: String) -> PowerDnsResult<()> {
Self::transform_response::<()>(
self.get_request_for(Method::DELETE, format!("tsigkeys/{}", name)).send().await?,
StatusCode::NO_CONTENT,
).await
}
fn get_request_for(self, method: Method, endpoint: String) -> RequestBuilder {
let url = format!("{}api/v1/servers/{}/{}", self.api_url, self.server_id, endpoint);
println!("Requesting {} {}", method, url);
self.http_client.request(
method,
url,
)
.header("X-API-Key", self.api_key)
}
async fn transform_response<T: DeserializeOwned>(res: Response, expected: StatusCode) -> PowerDnsResult<T> {
println!("Received response with statuscode={}", res.status());
let unknown_status_error = |code: StatusCode| -> PowerDnsError {PowerDnsApiError {
error: format!("Unexpected server status code: {}", code),
errors: None,
}.into()};
// TODO: Check if status_code is the 'correct' one for the endpoint
match res.status() {
StatusCode::OK |
StatusCode::CREATED |
StatusCode::NO_CONTENT => {
if res.status() == expected { Ok(res.json().await?) } else { Err(unknown_status_error(res.status())) }
},
StatusCode::BAD_REQUEST |
StatusCode::NOT_FOUND |
StatusCode::UNPROCESSABLE_ENTITY |
StatusCode::INTERNAL_SERVER_ERROR => Err(PowerDnsError::Api(res.json::<PowerDnsApiError>().await?)),
_ => Err(unknown_status_error(res.status()).into()),
}
}
}

7
templates/app_home.html Normal file
View File

@ -0,0 +1,7 @@
{% extends "base.html" %}
{% block content %}
<h1><code>pdns-tsig-key-manager</code></h1>
<small><code>v0.0.1</code></small>
{% endblock content %}

23
templates/base.html Normal file
View File

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>pdns-tsig-key-manager</title>
</head>
<body data-bs-theme="dark">
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="nav-link" href="/">Home</a>
<a class="nav-link" href="/user/home">User Home</a>
<a class="nav-link" href="/openid/login">User Login</a>
</div>
</nav>
<div class="container p-4">
{% block content %}
{% endblock content%}
</div>
</body>
</html>

5
templates/user_home.html Normal file
View File

@ -0,0 +1,5 @@
{% extends "base.html" %}
{% block content %}
<h1> User Home</h1>
{% endblock content %}