feat: initial commit
This commit is contained in:
commit
436dbe1393
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
config.yaml
|
||||
/target
|
3073
Cargo.lock
generated
Normal file
3073
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
Cargo.toml
Normal file
23
Cargo.toml
Normal 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
13
config.example.yml
Normal 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
46
src/config.rs
Normal 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
1
src/error/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod powerdns;
|
70
src/error/powerdns.rs
Normal file
70
src/error/powerdns.rs
Normal 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
37
src/handlers/api.rs
Normal 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
12
src/handlers/app.rs
Normal 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
4
src/handlers/mod.rs
Normal 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
65
src/handlers/openid.rs
Normal 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
12
src/handlers/user.rs
Normal 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
61
src/main.rs
Normal 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
1
src/model/mod.rs
Normal file
@ -0,0 +1 @@
|
||||
pub mod tsigkey;
|
29
src/model/tsigkey.rs
Normal file
29
src/model/tsigkey.rs
Normal 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
20
src/util/askama.rs
Normal 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
3
src/util/mod.rs
Normal file
@ -0,0 +1,3 @@
|
||||
pub(crate) mod askama;
|
||||
pub(crate) mod openid;
|
||||
pub(crate) mod powerdns;
|
28
src/util/openid.rs
Normal file
28
src/util/openid.rs
Normal 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
109
src/util/powerdns.rs
Normal 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
7
templates/app_home.html
Normal 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
23
templates/base.html
Normal 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
5
templates/user_home.html
Normal file
@ -0,0 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h1> User Home</h1>
|
||||
{% endblock content %}
|
Loading…
Reference in New Issue
Block a user