Compare commits
21 commits
main
...
rewrite-v0
Author | SHA1 | Date | |
---|---|---|---|
0a44b333a0 | |||
a306f6002b | |||
3751827779 | |||
a4e0c9a244 | |||
bdcbcf66ea | |||
d97d413d7a | |||
54eb6206c9 | |||
5f73738465 | |||
08b21ac010 | |||
cfdd9afc0e | |||
91bffe153a | |||
06fddae344 | |||
95d38c5514 | |||
1fd5ce890b | |||
7cee790c85 | |||
7e3e927946 | |||
76aa894123 | |||
c1d09cd391 | |||
419b78b55e | |||
|
376a6bd319 | ||
|
39cef3b600 |
77 changed files with 6825 additions and 3905 deletions
2355
Cargo.lock
generated
2355
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
48
Cargo.toml
48
Cargo.toml
|
@ -1,35 +1,31 @@
|
|||
[package]
|
||||
name = "nomilo"
|
||||
version = "0.1.0-dev"
|
||||
authors = ["DNS Witch Collective <dns-witch@familier.net.eu.org>"]
|
||||
edition = "2021"
|
||||
version = "0.2.0-dev"
|
||||
authors = ["DNS Witch Collective <dns-witch@dns-witch.eu.org>"]
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-or-later"
|
||||
readme = "README.md"
|
||||
repository = "https://git.familier.net.eu.org/dns-witch/nomilo"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
trust-dns-client = { version = "0.22", features = ["dnssec-openssl"] }
|
||||
trust-dns-proto = "0.22"
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rocket = { version = "0.5.0-rc.2", features = ["json"], default-features = false }
|
||||
rocket_sync_db_pools = { default-features = false, features = ["diesel_sqlite_pool"], version = "0.1.0-rc.2"}
|
||||
base64 = "0.21"
|
||||
uuid = { version = "0.8", features = ["v4", "serde"] }
|
||||
diesel = { version = "1.4", features = ["sqlite", "chrono"] }
|
||||
diesel_migrations = "1.4"
|
||||
diesel-derive-enum = { version = "1", features = ["sqlite"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
humantime = "2.1"
|
||||
tokio = "1"
|
||||
figment = { version = "0.10", features = ["toml", "env"] }
|
||||
clap = {version = "3", features = ["derive", "cargo"]}
|
||||
argon2 = {version = "0.4", default-features = false, features = ["alloc", "password-hash"] }
|
||||
rand = "0.8"
|
||||
tera = {version = "1", default-features = false}
|
||||
# From trust-dns-client
|
||||
futures-util = { version = "0.3", default-features = false, features = ["std"] }
|
||||
# From rocket / cookie-rs
|
||||
time = "0.3"
|
||||
#uuid = { version = "1.11", features = ["v4", "serde"] }
|
||||
#chrono = { version = "0.4", features = ["serde"] }
|
||||
#humantime = "2.1"
|
||||
tokio = {version = "1", default-features = false, features = [ "macros", "rt-multi-thread" ] }
|
||||
#clap = { version = "4", features = [ "derive", "cargo" ] }
|
||||
#argon2 = { version = "0.5", default-features = false, features = ["alloc", "password-hash"] }
|
||||
#rand = "0.8"
|
||||
tera = { version = "1", default-features = false }
|
||||
domain = { version = "0.10.3", features = [ "tsig", "unstable-client-transport" ]}
|
||||
axum = { version = "0.8.1", default-features = false, features = [ "http1", "json", "form", "query", "tokio", "original-uri" ]}
|
||||
bb8 = { version = "0.9" }
|
||||
rusqlite = { version = "0.32"}
|
||||
async-trait = { version = "0.1" }
|
||||
tower-http = { version = "0.6", default-features = false, features = [ "fs" ]}
|
||||
fluent-bundle = "0.15.3"
|
||||
unic-langid = "*"
|
||||
tower = "*"
|
||||
serde_urlencoded = "*"
|
||||
|
|
13
README.md
13
README.md
|
@ -2,16 +2,3 @@
|
|||
|
||||
> This projet is in a very experimental state
|
||||
|
||||
## Quick start
|
||||
|
||||
```
|
||||
nomilo server run
|
||||
nomilo user add --name Admin --password supersecret --email admin@localhost --is-admin
|
||||
```
|
||||
|
||||
Or if you prefer to not run the webserver before setting up the first user
|
||||
```
|
||||
nomilo server migrate
|
||||
nomilo user add --name Admin --password supersecret --email admin@localhost --is-admin
|
||||
nomilo server run
|
||||
```
|
||||
|
|
BIN
assets/fonts/inclusive-sans/InclusiveSans_wght.ttf
Normal file
BIN
assets/fonts/inclusive-sans/InclusiveSans_wght.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/inclusive-sans/InclusiveSans_wght.woff2
Normal file
BIN
assets/fonts/inclusive-sans/InclusiveSans_wght.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/lexend/Lexend-VariableFont_wght.ttf
Normal file
BIN
assets/fonts/lexend/Lexend-VariableFont_wght.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/lexend/Lexend-VariableFont_wght.woff2
Normal file
BIN
assets/fonts/lexend/Lexend-VariableFont_wght.woff2
Normal file
Binary file not shown.
55
assets/scripts/add-form-row.js
Normal file
55
assets/scripts/add-form-row.js
Normal file
|
@ -0,0 +1,55 @@
|
|||
function templateString(text, args) {
|
||||
for (const argName in args) {
|
||||
text = text.replace(`{${argName}}`, args[argName]);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
function addFormRow(templateName) {
|
||||
const allRows = document.querySelectorAll(`[data-new-item-template="${templateName}"]`);
|
||||
const templateElement = allRows[0];
|
||||
const nextId = allRows.length + 1;
|
||||
|
||||
const newItem = templateElement.cloneNode(true);
|
||||
|
||||
newItem.querySelectorAll('[data-new-item-skip]')
|
||||
.forEach(node => node.remove());
|
||||
|
||||
// Reset form fields
|
||||
newItem.querySelectorAll('input')
|
||||
.forEach(input => { input.value = '' });
|
||||
|
||||
newItem.querySelectorAll('textarea')
|
||||
.forEach(input => { input.value = '' });
|
||||
|
||||
// Template attributes
|
||||
const templatedAttrNodes = newItem.querySelectorAll('[data-new-item-template-attr]');
|
||||
for (const node of templatedAttrNodes) {
|
||||
const attributes = node.dataset.newItemTemplateAttr.split(/\s/);
|
||||
for (const attribute of attributes) {
|
||||
const templatedString = node.getAttribute(`data-template-${attribute}`);
|
||||
node.setAttribute(attribute, templateString(templatedString, {i: nextId}));
|
||||
}
|
||||
}
|
||||
|
||||
// Template content
|
||||
const templatedNodes = newItem.querySelectorAll('[data-new-item-template-content]');
|
||||
for (const node of templatedNodes) {
|
||||
const templatedString = node.dataset.newItemTemplateContent;
|
||||
node.innerHTML = templateString(templatedString, {i: nextId})
|
||||
}
|
||||
|
||||
allRows[allRows.length - 1].insertAdjacentElement('afterend', newItem);
|
||||
}
|
||||
|
||||
function setUpAddFormRow() {
|
||||
const buttons = document.querySelectorAll('button[data-new-item]');
|
||||
|
||||
for (const button of buttons) {
|
||||
button.addEventListener('click', () => addFormRow(button.dataset.newItem))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', setUpAddFormRow);
|
301
assets/styles/main.css
Normal file
301
assets/styles/main.css
Normal file
|
@ -0,0 +1,301 @@
|
|||
@font-face {
|
||||
font-family: 'Lexend';
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
src: url("/assets/fonts/lexend/Lexend-VariableFont_wght.woff2") format("woff2"),
|
||||
url("/assets/fonts/lexend/Lexend-VariableFont_wght.ttf") format("truetype");
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
font-family: 'Lexend', 'sans';
|
||||
font-size: 18px;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
*, *::after, *::before {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 55rem;
|
||||
margin: auto;
|
||||
padding: .5rem
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
article.domain {
|
||||
margin-bottom: 2em;
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 3em;
|
||||
|
||||
h3.folder-tab {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 0 1rem;
|
||||
border-top-left-radius: .3rem;
|
||||
background-color: #f2e0fd;
|
||||
margin: 0;
|
||||
font-weight: inherit;
|
||||
font-size: 1.3rem;
|
||||
|
||||
~ .sep {
|
||||
content: '';
|
||||
width: 3em;
|
||||
background-color: #f2e0fd;
|
||||
height: 100%;
|
||||
clip-path: url("#corner-folder-tab-right");
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.records {
|
||||
background: #f2e0fd;
|
||||
padding: 1rem;
|
||||
border-radius: 0 .3rem .3rem .3rem;
|
||||
|
||||
button,
|
||||
a.button {
|
||||
background-color: #f2e0fd;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
> ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.rrset {
|
||||
.rtype {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: .5em;
|
||||
}
|
||||
|
||||
ul {
|
||||
padding: 1rem 0 1rem 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
align-items: baseline;
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
.rdata {
|
||||
display: flex;
|
||||
gap: .2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rdata-main {
|
||||
display: flex;
|
||||
gap: .3rem;
|
||||
margin-right: .1rem;
|
||||
|
||||
.pill {
|
||||
background-color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.rdata-complementary {
|
||||
font-size: .9em;
|
||||
gap: .2rem;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.action {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
position: relative;
|
||||
top: .15rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pill {
|
||||
border: .1rem solid #bd79bd;
|
||||
border-radius: .3rem;
|
||||
padding: 0 .2em;
|
||||
}
|
||||
|
||||
button,
|
||||
a.button {
|
||||
border: .2rem solid #850085;
|
||||
border-radius: 1.4em;
|
||||
padding: .2em .8em;
|
||||
color: #850085;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: .3em;
|
||||
background-color: white;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color .2s, color .2s;
|
||||
|
||||
svg {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
&.icon {
|
||||
padding: 0;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: white;
|
||||
background-color: #850085;
|
||||
}
|
||||
}
|
||||
|
||||
form h3 {
|
||||
margin: 0;
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&::after {
|
||||
/*content: '';*/
|
||||
height: 1px;
|
||||
flex: 1;
|
||||
display: block;
|
||||
background-color: black;
|
||||
margin-left: .5em;
|
||||
}
|
||||
}
|
||||
|
||||
fieldset {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
margin-top: .5rem;
|
||||
|
||||
border-left: .25rem solid #a8a8a8;
|
||||
padding-left: .5rem;
|
||||
|
||||
legend {
|
||||
padding: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 400;
|
||||
|
||||
position: relative;
|
||||
bottom: -.55em;
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
& + button.form-new-item {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-template-rows: auto auto;
|
||||
grid-auto-columns: 1fr;
|
||||
column-gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
|
||||
& > div {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: #a20000;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
margin-top: .15rem;
|
||||
padding: .25rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.error::before {
|
||||
content: '';
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
background-color: #fff;
|
||||
mask-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-exclamation-triangle' viewBox='0 0 16 16'><path d='M7.938 2.016A.13.13 0 0 1 8.002 2a.13.13 0 0 1 .063.016.15.15 0 0 1 .054.057l6.857 11.667c.036.06.035.124.002.183a.2.2 0 0 1-.054.06.1.1 0 0 1-.066.017H1.146a.1.1 0 0 1-.066-.017.2.2 0 0 1-.054-.06.18.18 0 0 1 .002-.183L7.884 2.073a.15.15 0 0 1 .054-.057m1.044-.45a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767z'/><path d='M7.002 12a1 1 0 1 1 2 0 1 1 0 0 1-2 0M7.1 5.995a.905.905 0 1 1 1.8 0l-.35 3.507a.552.552 0 0 1-1.1 0z'/></svg>");
|
||||
display: inline-block;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
mask-size: cover;
|
||||
margin-right: .25rem;
|
||||
margin-bottom: -.05em;
|
||||
}
|
||||
|
||||
.help {
|
||||
margin: 0;
|
||||
margin-top: .15rem;
|
||||
font-style: oblique;
|
||||
font-size: .9rem;
|
||||
}
|
||||
|
||||
input,
|
||||
textarea {
|
||||
padding: .25rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.form-action {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
button.form-new-item {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
border-radius: 0;
|
||||
color: #850085;
|
||||
margin-top: .5rem;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background: none;
|
||||
text-decoration: none;
|
||||
color: #850085;
|
||||
}
|
||||
}
|
58
dev-scripts/config/named.conf
Normal file
58
dev-scripts/config/named.conf
Normal file
|
@ -0,0 +1,58 @@
|
|||
options {
|
||||
directory "/var/cache/bind";
|
||||
listen-on port 5354 { any; };
|
||||
listen-on-v6 port 5354 { any; };
|
||||
|
||||
empty-zones-enable no;
|
||||
|
||||
allow-recursion {
|
||||
none;
|
||||
};
|
||||
allow-transfer {
|
||||
none;
|
||||
};
|
||||
allow-update {
|
||||
none;
|
||||
};
|
||||
|
||||
};
|
||||
|
||||
logging {
|
||||
channel console {
|
||||
stderr;
|
||||
severity debug;
|
||||
};
|
||||
|
||||
|
||||
category default { console; };
|
||||
category general { console; };
|
||||
category database { console; };
|
||||
category security { console; };
|
||||
category config { console; };
|
||||
category resolver { console; };
|
||||
category xfer-in { console; };
|
||||
category xfer-out { console; };
|
||||
category notify { console; };
|
||||
category client { console; };
|
||||
category unmatched { console; };
|
||||
category queries { console; };
|
||||
category network { console; };
|
||||
category update { console; };
|
||||
category dispatch { console; };
|
||||
category dnssec { console; };
|
||||
category lame-servers { console; };
|
||||
};
|
||||
|
||||
key "dev" {
|
||||
algorithm HMAC-SHA256;
|
||||
secret "mbmz4J3Efm1BUjqe12M1RHsOnPjYhKQe+2iKO4tL+a4=";
|
||||
};
|
||||
|
||||
|
||||
zone "example.com." {
|
||||
type primary;
|
||||
file "/var/lib/bind/example.com.zone";
|
||||
notify explicit;
|
||||
allow-transfer { key "dev"; };
|
||||
allow-update { key "dev"; };
|
||||
};
|
|
@ -1,8 +1,16 @@
|
|||
services:
|
||||
|
||||
knot:
|
||||
image: cznic/knot
|
||||
image: cznic/knot:3.4
|
||||
volumes:
|
||||
- ./zones:/storage/zones:ro
|
||||
- ./config:/config:ro
|
||||
command: knotd
|
||||
command: knotd --verbose
|
||||
network_mode: host
|
||||
named:
|
||||
image: internetsystemsconsortium/bind9:9.20
|
||||
volumes:
|
||||
- ./zones:/var/lib/bind:ro
|
||||
- ./config:/etc/bind:ro
|
||||
#command: named -g
|
||||
network_mode: host
|
||||
|
|
|
@ -6,7 +6,16 @@ example.com. IN SOA ns.example.com. admin.example.com. (
|
|||
300 ; minimum (5 minutes)
|
||||
)
|
||||
|
||||
example.com. 600 IN A 198.51.100.3
|
||||
example.com. 600 IN AAAA 2001:db8:cafe:bc68::2
|
||||
|
||||
example.com. 3600 IN MX 1 srv1.example.com.
|
||||
example.com. 3600 IN MX 100 mail.example.net.
|
||||
|
||||
_imap._tcp.example.com. 3600 IN SRV 1 1 143 mail.example.net.
|
||||
|
||||
example.com. 84600 IN NS ns.example.com.
|
||||
ns.example.com. 84600 IN A 198.51.100.3
|
||||
|
||||
srv1.example.com. 600 IN A 198.51.100.3
|
||||
srv1.example.com. 600 IN AAAA 2001:db8:cafe:bc68::2
|
||||
|
|
95
locales/en/main.ftl
Normal file
95
locales/en/main.ftl
Normal file
|
@ -0,0 +1,95 @@
|
|||
## Zone content
|
||||
|
||||
zone-header = Zone { $zone_name }
|
||||
|
||||
zone-content-title = Zone content
|
||||
zone-content-records-header = Records
|
||||
zone-content-aliases-header = Aliases
|
||||
|
||||
zone-content-section-web-header = Web
|
||||
zone-content-section-mail-header = E-mail
|
||||
zone-content-section-services-header = Services
|
||||
zone-content-section-general-header = General
|
||||
|
||||
zone-content-record-type-addresses =
|
||||
.type-name = IP addresses
|
||||
|
||||
zone-content-record-type-mailservers =
|
||||
.type-name = E-mail servers
|
||||
.data-preference = Preference: { $preference }
|
||||
|
||||
zone-content-record-type-nameservers =
|
||||
.type-name = Name servers
|
||||
|
||||
zone-content-record-type-service =
|
||||
.type-name = Service
|
||||
.data-priority = Priority: { $priority }
|
||||
.data-weight = Weight: { $weight }
|
||||
|
||||
zone-content-new-record-button = New record
|
||||
|
||||
## Create record
|
||||
|
||||
new-record-title = New record
|
||||
|
||||
record-creation-process-heading = Create a new record in zone { $zone }
|
||||
|
||||
record-choose-name-heading = Choose the subdomain name of the new record
|
||||
|
||||
record-input-name =
|
||||
.input-label = Subdomain of the new record
|
||||
.help-description = Only the subdomain, without the parent domain. For instance, "www" to create the subdomain "www.{ $zone }".
|
||||
.error-domain-characters_not_permitted = Domain name label "{ $label }" contains characters not permitted. The allowed characters are lowercase alphanumeric characters (a-z and 0-9), the dash ('-'), the underscore ('_') and the forward slash ('/').
|
||||
|
||||
button-create-record-next-step = Next step
|
||||
|
||||
record-input-ttl =
|
||||
.input-label = Duration in cache (TTL)
|
||||
.help = In seconds, optional, default to 1 hour
|
||||
.error-input-type_error = The duration in cache must be a positif integer number.
|
||||
.error-record-parse-number = The duration in cache must be a positif integer number.
|
||||
|
||||
record-input-addresses =
|
||||
.input-label = IP address #{ $index }
|
||||
.error-record-parse-ip = Unexpected IP address format. The IP address
|
||||
should be either an IPv4 address, like <code>198.51.100.3</code>, or an IPv6
|
||||
address, like <code>2001:db8:cafe:bc68::2</code>.
|
||||
.error-input-missing_value = At least one IP addresses is required.
|
||||
|
||||
button-add-address = Add an other address
|
||||
|
||||
record-input-mailservers =
|
||||
.legend = Mail server #{ $index }
|
||||
.input-label-server-name = Server name
|
||||
.input-label-preference = Preference
|
||||
|
||||
button-add-mailserver = Add an other mailserver
|
||||
|
||||
record-input-spf =
|
||||
.legend = Sender policy (SPF)
|
||||
.input-label = Sender policy
|
||||
|
||||
record-input-dmarc =
|
||||
.legend = Error reporting policy (DMARC)
|
||||
.input-label = Error reporting policy
|
||||
|
||||
record-input-dkim =
|
||||
.legend = Cryptographic signature key (DKIM) #{ $index }
|
||||
.input-label-selector = Selector
|
||||
.input-label-signing-key = Signing key
|
||||
|
||||
button-add-dkim-key = Add an other signature key
|
||||
|
||||
record-config-web-heading = Configure a web site for the domain <strong>{ $name }</strong>
|
||||
|
||||
record-config-section-web =
|
||||
.servers = Web servers
|
||||
|
||||
record-config-mail-heading = Configure e-mails for the domain <strong>{ $name }</strong>
|
||||
|
||||
record-config-section-mail =
|
||||
.servers = Mail servers
|
||||
.security = Security
|
||||
.dkim = Cryptographic signature
|
||||
|
||||
button-save-configuration = Save configuration
|
95
locales/fr/main.ftl
Normal file
95
locales/fr/main.ftl
Normal file
|
@ -0,0 +1,95 @@
|
|||
## Zone content
|
||||
|
||||
zone-header = Zone { $zone_name }
|
||||
|
||||
zone-content-title = Contenu de la zone
|
||||
zone-content-records-header = Enregistrements
|
||||
zone-content-aliases-header = Alias
|
||||
|
||||
zone-content-section-web-header = Web
|
||||
zone-content-section-mail-header = Courriel
|
||||
zone-content-section-services-header = Services
|
||||
zone-content-section-general-header = Général
|
||||
|
||||
zone-content-record-type-addresses =
|
||||
.type-name = Adresses IP
|
||||
|
||||
zone-content-record-type-mailservers =
|
||||
.type-name = Serveurs de courriel
|
||||
.data-preference = Préférence : { $preference }
|
||||
|
||||
zone-content-record-type-nameservers =
|
||||
.type-name = Serveurs de noms
|
||||
|
||||
zone-content-record-type-service =
|
||||
.type-name = Services
|
||||
.data-priority = Priorité : { $priority }
|
||||
.data-weight = Poids : { $weight }
|
||||
|
||||
zone-content-new-record-button = Nouvel enregistrement
|
||||
|
||||
## Create record
|
||||
|
||||
new-record-title = Nouvel enregistrement
|
||||
|
||||
record-creation-process-heading = Créer un nouvel enregistrement dans la zone { $zone }
|
||||
|
||||
record-choose-name-heading = Choisir le nom du sous-domaine du nouvel enregistrement
|
||||
|
||||
record-input-name =
|
||||
.input-label = Sous-domaine du nouvel enregistrement
|
||||
.help-description = Seulement le sous-domaine, sans le domaine parent. Par exemple, “www” pour créer le sous-domaine “www.{ $zone }”.
|
||||
.error-domain-characters_not_permitted = Le segment “{ $label }” du nom de domain contient des caractères interdits. Les caractères autorisés sont les caractères alphanumériques (a-z et 0-9), le tiret (“-”), le tiret bas (“_”) et le slash (“/”).
|
||||
|
||||
button-create-record-next-step = Étape suivante
|
||||
|
||||
record-input-ttl =
|
||||
.input-label = Durée dans le cache (TTL)
|
||||
.help = En secondes, optionnel, 1 heure par défaut
|
||||
.error-input-type_error = La durée dans le cache doit être un nombre entier positif.
|
||||
.error-record-parse-number = La durée dans le cache doit être un nombre entier positif.
|
||||
|
||||
record-input-addresses =
|
||||
.input-label = Adresse IP #{ $index }
|
||||
.error-record-parse-ip = Format d’adresse IP inconnu. L’adresse IP doit être
|
||||
soit une adresse IPv4, comme <code>198.51.100.3</code>, soit une adresse IPv6,
|
||||
comme <code>2001:db8:cafe:bc68::2</code>.
|
||||
.error-input-missing_value = Au moins une adresse IP est requise.
|
||||
|
||||
button-add-address = Ajouter une autre adresse
|
||||
|
||||
record-input-mailservers =
|
||||
.legend = Serveur de courriel #{ $index }
|
||||
.input-label-server-name = Nom du serveur
|
||||
.input-label-preference = Préférence
|
||||
|
||||
button-add-mailserver = Ajouter un autre serveur de courriel
|
||||
|
||||
record-input-spf =
|
||||
.input-label = Politique d’envoi
|
||||
.legend = Politique d’envoi (SPF)
|
||||
|
||||
record-input-dmarc =
|
||||
.legend = Politique de signalement d’erreurs (DMARC)
|
||||
.input-label = Politique de signalement d’erreurs
|
||||
|
||||
record-input-dkim =
|
||||
.legend = Clé de signature cryptographique #{ $index }
|
||||
.input-label-selector = Sélecteur
|
||||
.input-label-signing-key = Clé de signature
|
||||
|
||||
button-add-dkim-key = Ajouter une autre clé de signature
|
||||
|
||||
record-config-web-heading = Configurer un site web pour le domaine <strong>{ $name }</strong>
|
||||
|
||||
record-config-section-web =
|
||||
.servers = Serveurs Web
|
||||
|
||||
record-config-mail-heading = Configurer le courriel pour le domaine <strong>{ $name }</strong>
|
||||
|
||||
record-config-section-mail =
|
||||
.servers = Serveurs de courriel
|
||||
.security = Sécurité
|
||||
.dkim = Signature cryptographique (DKIM)
|
||||
|
||||
button-save-configuration = Sauvegarder la configuration
|
|
@ -1,3 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus" viewBox="0 0 16 16">
|
||||
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
|
||||
</svg>
|
Before Width: | Height: | Size: 245 B |
|
@ -1,44 +0,0 @@
|
|||
const baseUrl = '/api/v1';
|
||||
|
||||
|
||||
function apiGet(url) {
|
||||
return fetch(`${baseUrl}/${url}`)
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
// do something here
|
||||
throw new Error('Not ok');
|
||||
}
|
||||
return res.json();
|
||||
});
|
||||
}
|
||||
|
||||
function apiPost(url, data) {
|
||||
return fetch(`${baseUrl}/${url}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
.then(res => {
|
||||
if (!res.ok) {
|
||||
// do something here
|
||||
throw new Error('Not ok');
|
||||
}
|
||||
return res.json();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function getRecords(zone) {
|
||||
return apiGet(`zones/${zone}/records`);
|
||||
}
|
||||
|
||||
function createRecords(zone, record) {
|
||||
return apiPost(`zones/${zone}/records`, record);
|
||||
}
|
||||
|
||||
export {
|
||||
getRecords,
|
||||
createRecords,
|
||||
};
|
|
@ -1,328 +0,0 @@
|
|||
import { html, render, useState, useEffect } from './vendor/preact/standalone.js';
|
||||
|
||||
import { getRecords, createRecords } from './api.js';
|
||||
|
||||
|
||||
const rdataInputProperties = {
|
||||
Address: {label: 'Adresse', type: 'text'},
|
||||
Serial: {label: 'Numéro de série', type: 'number'},
|
||||
Minimum: {label: 'Minimum', type: 'number'},
|
||||
Retry: {label: 'Nouvelle tentative', type: 'number'},
|
||||
Refresh: {label: 'Actualisation', type: 'number'},
|
||||
MaintainerName: {label: 'Contact', type: 'text'},
|
||||
MasterServerName: {label: 'Serveur primaire', type: 'text'},
|
||||
Expire: {label: 'Expiration', type: 'number'},
|
||||
Target: {label: 'Cible', type: 'text'},
|
||||
Service: {label: 'Service', type: 'text'},
|
||||
Protocol: {label: 'Protocole', type: 'text'},
|
||||
Priority: {label: 'Priorité', type: 'number'},
|
||||
Weight: {label: 'Poids', type: 'number'},
|
||||
Port: {label: 'Port', type: 'number'},
|
||||
Server: {label: 'Serveur', type: 'text'},
|
||||
};
|
||||
|
||||
const realRecordDataConfig = {
|
||||
'A': {
|
||||
friendlyType: 'address',
|
||||
fields: ['Address'],
|
||||
},
|
||||
'AAAA': {
|
||||
friendlyType: 'address',
|
||||
fields: ['Address'],
|
||||
},
|
||||
'CNAME': {
|
||||
friendlyType: 'alias',
|
||||
fields: ['Target'],
|
||||
},
|
||||
'SRV': {
|
||||
friendlyType: 'service',
|
||||
fields: [ 'Priority', 'Weight', 'Port', 'Server' ],
|
||||
},
|
||||
'NS': {
|
||||
friendlyType: 'name_server',
|
||||
fields: ['Target'],
|
||||
},
|
||||
'SOA': {
|
||||
friendlyType: 'soa',
|
||||
fields: ['MasterServerName', 'MaintainerName', 'Refresh', 'Retry', 'Expire', 'Minimum', 'Serial'],
|
||||
},
|
||||
};
|
||||
|
||||
function defaultBuildData(realRecordType) {
|
||||
const defaultFields = Object.fromEntries(realRecordDataConfig[realRecordType].fields.map(field => [field, null]));
|
||||
return (fields) => {
|
||||
return {...defaultFields, ...fields, Type: realRecordType};
|
||||
}
|
||||
}
|
||||
|
||||
function defaultRecordToFields(realRecord) {
|
||||
const type = realRecord.Type;
|
||||
return realRecordDataConfig[type].fields.map(field => [field, realRecord[field]]);
|
||||
}
|
||||
|
||||
function defaultGetName(name) {
|
||||
return name;
|
||||
}
|
||||
|
||||
function srvRecordToFields({ Name, Type, Class, ...fields }) {
|
||||
const [ serviceName, protocol] = Name.split('.');
|
||||
return {
|
||||
Service: serviceName.replace(/^_/, ''),
|
||||
Protocol: protocol.replace(/^_/, ''),
|
||||
...fields
|
||||
}
|
||||
}
|
||||
|
||||
function srvGetName(originalName) {
|
||||
const [_serviceName, _protocol, ...name] = originalName.split('.');
|
||||
return name.join('.');
|
||||
}
|
||||
|
||||
function buildAddressRecord(fields) {
|
||||
const address = fields.Address || '';
|
||||
if (address.indexOf('.') >= 0) {
|
||||
fields.Type = 'A';
|
||||
} else if (address.indexOf(':') >= 0) {
|
||||
fields.Type = 'AAAA';
|
||||
} else {
|
||||
fields.Type = '';
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function buildServiceRecord({ Name, Service, Protocol, ...fields}) {
|
||||
fields.Name = `_${Service}._${Protocol}.${Name}`;
|
||||
fields.Type = 'SRV';
|
||||
return fields;
|
||||
}
|
||||
|
||||
const friendlyRecordDataConfig = {
|
||||
'address': {
|
||||
realRecordToFields: defaultRecordToFields,
|
||||
fields: realRecordDataConfig['AAAA'].fields,
|
||||
buildData: buildAddressRecord,
|
||||
getName: defaultGetName,
|
||||
},
|
||||
'alias': {
|
||||
realRecordToFields: defaultRecordToFields,
|
||||
fields: realRecordDataConfig['CNAME'].fields,
|
||||
buildData: defaultBuildData('CNAME'),
|
||||
getName: defaultGetName,
|
||||
},
|
||||
'name_server': {
|
||||
realRecordToFields: defaultRecordToFields,
|
||||
fields: realRecordDataConfig['NS'].fields,
|
||||
buildData: defaultBuildData('NS'),
|
||||
getName: defaultGetName,
|
||||
},
|
||||
'soa': {
|
||||
realRecordToFields: defaultRecordToFields,
|
||||
fields: realRecordDataConfig['SOA'].fields,
|
||||
buildData: defaultBuildData('SOA'),
|
||||
getName: defaultGetName,
|
||||
},
|
||||
'service': {
|
||||
realRecordToFields: srvRecordToFields,
|
||||
fields: ['Service', 'Protocol', 'Priority', 'Weight', 'Port', 'Server'],
|
||||
buildData: buildServiceRecord,
|
||||
getName: srvGetName,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
const recordTypeNames = {
|
||||
'address': 'Adresse IP',
|
||||
'service': 'Service',
|
||||
'alias': 'Alias',
|
||||
'name_server': 'Serveur de nom',
|
||||
'soa': 'SOA',
|
||||
}
|
||||
|
||||
/* Name to use with spf for example */
|
||||
function getFriendlyTypeForRecord(name, type) {
|
||||
return realRecordDataConfig[type].friendlyType;
|
||||
}
|
||||
|
||||
function processRecords(records) {
|
||||
return records.reduce((acc, record) => {
|
||||
let type = getFriendlyTypeForRecord(record.Name, record.Type);
|
||||
let name = friendlyRecordDataConfig[type].getName(record.Name);
|
||||
if (!(name in acc)) {
|
||||
acc[name] = {};
|
||||
}
|
||||
if (!(type in acc[name])) {
|
||||
acc[name][type] = [];
|
||||
}
|
||||
acc[name][type].push(record);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function FriendlyRecord({type, record}) {
|
||||
let keys = friendlyRecordDataConfig[type].realRecordToFields(record);
|
||||
if (keys.length == 1) {
|
||||
return html`<span>${keys[0][1]}</span>`;
|
||||
} else {
|
||||
return html`
|
||||
<dl>
|
||||
${keys.map(([name, value]) => {return html`<dt><span>${rdataInputProperties[name].label}</span></dt><dd>${value}</dd>`})}
|
||||
</dl>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function RecordsByName({ name, recordSets }) {
|
||||
return html`
|
||||
<article class="rrsets-per-name">
|
||||
<h3 class="record-name">${name}</h4>
|
||||
<div class="rrsets-per-type">
|
||||
${Object.entries(recordSets).map(
|
||||
([type, records]) => {
|
||||
return html`
|
||||
<article class="rrset-per-type">
|
||||
<h4 class="record-type">${recordTypeNames[type]}</h4>
|
||||
<ul class="rrset-rdata">
|
||||
${records.map(record => html`<li><${FriendlyRecord} type=${type} record=${record}/></li>`)}
|
||||
</ul>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function RecordListFriendly({ zone }) {
|
||||
const [records, setRecords] = useState({});
|
||||
const [editable, setEditable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
getRecords(zone)
|
||||
.then((res) => setRecords(processRecords(res)));
|
||||
}, [zone]);
|
||||
|
||||
return html`
|
||||
${Object.entries(records).map(
|
||||
([name, recordSets]) => {
|
||||
return html`
|
||||
<${RecordsByName} name=${name} recordSets=${recordSets}/>
|
||||
`;
|
||||
}
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
function NewRecordFormFriendly({ zone }) {
|
||||
const defaultVaules = {Name: '', TTL: 3600, Class: 'IN'};
|
||||
const [recordType, setRecordType] = useState(Object.keys(recordTypeNames)[0]);
|
||||
const [recordData, setRecordData] = useState(defaultVaules);
|
||||
const [realRecordData, setRealRecordData] = useState({});
|
||||
const [realType, setRealType] = useState('');
|
||||
|
||||
const absoluteName = (name) => name ? `${name}.${zone}` : zone;
|
||||
|
||||
const setRecordDataFactory = (field) => {
|
||||
return (e) => {
|
||||
const newData = {...recordData};
|
||||
newData[field] = e.target.type == 'number' ? Number(e.target.value) : e.target.value;
|
||||
const newRealRecordData = friendlyRecordDataConfig[recordType].buildData({...newData, Class: 'IN', Name: absoluteName(newData.Name)})
|
||||
|
||||
setRecordData(newData);
|
||||
setRealRecordData(newRealRecordData);
|
||||
setRealType(newRealRecordData.Type);
|
||||
}
|
||||
}
|
||||
|
||||
const createNewRecord = (e) => {
|
||||
e.preventDefault();
|
||||
const newRecords = [realRecordData];
|
||||
console.log(newRecords)
|
||||
createRecords(zone, newRecords);
|
||||
}
|
||||
|
||||
const resetData = (resetName = false) => {
|
||||
setRealType('');
|
||||
const newName = resetName ? defaultVaules.Name : recordData.Name;
|
||||
setRecordData({ Name: newName, TTL: defaultVaules.TTL });
|
||||
setRealRecordData({...defaultVaules, Name: absoluteName(newName)});
|
||||
}
|
||||
|
||||
useEffect(() => resetData(true), []);
|
||||
|
||||
// TODO: Reset valeurs champs quand changement de type + "annuler" => bound la valeur de l'input au state
|
||||
// TODO: Dans le cas où un domain est dans le RDATA mettre le domaine absolue dans la preview
|
||||
// TODO: Déplacer preview dans son component, faire une vue en "diff" et l'appeler "prévisualisation des changements"
|
||||
// TODO: Validation des données client et serveur
|
||||
|
||||
return html`
|
||||
<section class="new-record">
|
||||
<header>
|
||||
<h2>Nouvel enregistrement</h2>
|
||||
</header>
|
||||
<form>
|
||||
<div class="form-row">
|
||||
<div class="input-group">
|
||||
<label for="domain">Domaine</label>
|
||||
<div class="combined-input">
|
||||
<input type="text" id="domain" name="domain" value=${recordData.Name} onInput=${setRecordDataFactory('Name')}/>
|
||||
<span>.${ zone }</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="record_type">Type d'enregistrement</label>
|
||||
<select id="record_type" name="record_type" onChange=${(e) => { setRecordType(e.target.value); resetData() }}>
|
||||
${Object.entries(recordTypeNames).map(([type, name]) => html`<option value="${type}">${name}</option>`)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
${friendlyRecordDataConfig[recordType].fields.map(fieldName => html`
|
||||
<div class="input-group">
|
||||
<label for="${fieldName}">${rdataInputProperties[fieldName].label}</label>
|
||||
<input id="${fieldName}" type="${rdataInputProperties[fieldName].type}" onInput=${setRecordDataFactory(fieldName)}></input>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="ttl">Durée dans le cache</label>
|
||||
<input type="number" name="ttl" id="ttl" value=${recordData.TTL} onInput=${setRecordDataFactory('TTL')}/>
|
||||
</div>
|
||||
</div>
|
||||
<article class="preview">
|
||||
<h3>Prévisualisation des changements</h3>
|
||||
<p>
|
||||
<img src="/images/plus.svg" alt="Ajout" title="Ajout" class="addition"/>
|
||||
<code class="addition">
|
||||
${realRecordData.Name === zone ? '@' : realRecordData.Name} ${realRecordData.TTL} ${realRecordData.Class} ${realType} ${realType != '' ? realRecordDataConfig[realType].fields.map(field => realRecordData[field]).join(' ') : ''}
|
||||
</code>
|
||||
</p>
|
||||
</article>
|
||||
<div>
|
||||
<input type="submit" onClick=${createNewRecord} value="Ajouter"/>
|
||||
<button type="reset" onClick=${e => { resetData(true); e.preventDefault() }}>Annuler</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function ZoneRecords({ zone }) {
|
||||
return html`
|
||||
<${NewRecordFormFriendly} zone=${zone}/>
|
||||
|
||||
<section>
|
||||
<header>
|
||||
<h2>Contenu de la zone</h2>
|
||||
<button>Éditer la zone</button>
|
||||
</header>
|
||||
|
||||
<div class="zone-content">
|
||||
<${RecordListFriendly} zone=${zone} />
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
export default function(element, { zone }) {
|
||||
render(html`<${ZoneRecords} zone=${zone} />`, element);
|
||||
};
|
7
public/scripts/vendor/licenses.txt
vendored
7
public/scripts/vendor/licenses.txt
vendored
|
@ -1,7 +0,0 @@
|
|||
htm@3.1.1 - Apache-2.0
|
||||
Copyright 2018 Google Inc.
|
||||
Full license: ./preact/LICENSE-htm
|
||||
|
||||
preact@10.7.1 - MIT
|
||||
Copyright (c) 2015-present Jason Miller
|
||||
Full license: ./preact/LICENSE-preact
|
202
public/scripts/vendor/preact/LICENSE-htm
vendored
202
public/scripts/vendor/preact/LICENSE-htm
vendored
|
@ -1,202 +0,0 @@
|
|||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2018 Google Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
21
public/scripts/vendor/preact/LICENSE-preact
vendored
21
public/scripts/vendor/preact/LICENSE-preact
vendored
|
@ -1,21 +0,0 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2015-present Jason Miller
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
1
public/scripts/vendor/preact/standalone.js
vendored
1
public/scripts/vendor/preact/standalone.js
vendored
File diff suppressed because one or more lines are too long
|
@ -1,13 +0,0 @@
|
|||
form {
|
||||
flex-grow: 1;
|
||||
max-width: 40ch;
|
||||
margin: 25vh auto 0 auto;
|
||||
}
|
||||
|
||||
main {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
}
|
|
@ -1,158 +0,0 @@
|
|||
body {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
min-width: 100vw;
|
||||
margin: 0;
|
||||
font-family: sans-serif;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-primary: 94, 12, 151;
|
||||
--color-hightlight-1: 255, 212, 186;
|
||||
--color-hightlight-2: 208, 44, 167;
|
||||
--color-contrast: white;
|
||||
}
|
||||
|
||||
main {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
padding: 1rem;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgb(var(--color-primary));
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
padding-bottom: .3em;
|
||||
}
|
||||
|
||||
a::after {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 100%;
|
||||
border-bottom: 2px solid rgb(var(--color-primary));
|
||||
position: absolute;
|
||||
bottom: .1em;
|
||||
transition: .2s;
|
||||
}
|
||||
|
||||
a:hover::after {
|
||||
bottom: .3em;
|
||||
}
|
||||
|
||||
p.feedback {
|
||||
padding: .35rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p.feedback.error {
|
||||
background: #fddede;
|
||||
color: #710000;
|
||||
}
|
||||
|
||||
select,
|
||||
input {
|
||||
border: 1px solid rgb(var(--color-primary));;
|
||||
border-radius: 0;
|
||||
background: var(--color-contrast);
|
||||
}
|
||||
|
||||
select,
|
||||
button,
|
||||
input {
|
||||
padding: .35rem .35rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
button,
|
||||
input[type="submit"] {
|
||||
background: rgb(var(--color-primary));
|
||||
color: var(--color-contrast);
|
||||
border-left: 5px solid rgb(var(--color-hightlight-1));
|
||||
border-top: 5px solid rgb(var(--color-hightlight-1));
|
||||
border-right: 5px solid rgb(var(--color-hightlight-2));
|
||||
border-bottom: 5px solid rgb(var(--color-hightlight-2));
|
||||
}
|
||||
|
||||
button:hover:not([disabled]),
|
||||
input[type="submit"]:hover:not([disabled]) {
|
||||
background: rgba(var(--color-primary), .8);
|
||||
}
|
||||
|
||||
button:active:not([disabled]) ,
|
||||
input[type="submit"]:active:not([disabled]) {
|
||||
border-left: 5px solid rgb(var(--color-hightlight-2));
|
||||
border-top: 5px solid rgb(var(--color-hightlight-2));
|
||||
border-right: 5px solid rgb(var(--color-hightlight-1));
|
||||
border-bottom: 5px solid rgb(var(--color-hightlight-1));
|
||||
}
|
||||
|
||||
button[disabled],
|
||||
input[type="submit"][disabled] {
|
||||
background: rgba(var(--color-primary), .75);
|
||||
border-left-color: rgba(var(--color-hightlight-1), .75);
|
||||
border-top-color: rgba(var(--color-hightlight-1), .75);
|
||||
border-right-color: rgba(var(--color-hightlight-2), .75);
|
||||
border-bottom-color: rgba(var(--color-hightlight-2), .75);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
form input[type="submit"] {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
form label {
|
||||
margin-top: .75em;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
nav.main {
|
||||
background: rgb(var(--color-primary));
|
||||
min-width: 25ch;
|
||||
display: flex;
|
||||
flex: 0;
|
||||
padding: 1rem;
|
||||
border-right: 5px solid rgb(var(--color-hightlight-2));
|
||||
}
|
||||
|
||||
nav.main a {
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
nav.main a::after {
|
||||
border-bottom: 2px solid var(--color-contrast);
|
||||
}
|
||||
|
||||
|
||||
nav.main a img {
|
||||
filter: invert(100%);
|
||||
width: 1.4em;
|
||||
margin-bottom: -.3em;
|
||||
}
|
||||
|
||||
|
||||
nav.main ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
nav.main ul li {
|
||||
margin-top: .35rem;
|
||||
}
|
||||
|
||||
nav.main ul ul {
|
||||
margin-left: 1rem;
|
||||
}
|
|
@ -1,183 +0,0 @@
|
|||
nav.secondary ul {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
nav.secondary li {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
main > section {
|
||||
max-width: 120ch;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
header > :not(:last-of-type) {
|
||||
margin-right: 2ch;
|
||||
}
|
||||
|
||||
.zone-content article.rrsets-per-name {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
grid-gap: 2ch;
|
||||
margin: .5rem 0;
|
||||
}
|
||||
|
||||
.zone-content article.rrsets-per-name:not(:last-of-type) {
|
||||
border-bottom: 2px solid rgb(var(--color-hightlight-2));
|
||||
}
|
||||
|
||||
.zone-content h3.record-name,
|
||||
.zone-content h4.record-type {
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.zone-content h3.record-name {
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
|
||||
.zone-content div.rrsets-per-type {
|
||||
grid-column: 3 / 7;
|
||||
}
|
||||
|
||||
.zone-content article.rrset-per-type {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
display: grid;
|
||||
grid-gap: 2ch;
|
||||
}
|
||||
|
||||
.zone-content h4.record-type {
|
||||
grid-column: 1 / 2;
|
||||
}
|
||||
|
||||
.zone-content ul.rrset-rdata {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style-type: none;
|
||||
grid-column: 2 / 5;
|
||||
}
|
||||
|
||||
.zone-content ul.rrset-rdata dl {
|
||||
display: grid;
|
||||
grid-template: auto / max-content 1fr;
|
||||
}
|
||||
|
||||
.zone-content ul.rrset-rdata dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.zone-content ul.rrset-rdata dt span {
|
||||
display: inline-block;
|
||||
background-color: rgb(var(--color-hightlight-1));
|
||||
padding: 0.1em 0.5em;
|
||||
border-radius: 0.5em;
|
||||
margin-right: 0.1rem;
|
||||
font-size: .7rem;
|
||||
}
|
||||
|
||||
.new-record form {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.new-record form > div.form-row > * {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.new-record form > div.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2ch;
|
||||
}
|
||||
|
||||
.new-record form label {
|
||||
margin-top: .25rem;
|
||||
}
|
||||
|
||||
form div.input-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
form div.combined-input {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
form div.combined-input input {
|
||||
height: min-content;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
form div.combined-input span {
|
||||
font-size: .8rem;
|
||||
padding: .35rem;
|
||||
border: 1px solid rgb(var(--color-primary));;
|
||||
border-left: none;
|
||||
background: rgba(var(--color-hightlight-2),.2);
|
||||
}
|
||||
|
||||
form.disabled {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.new-record form button,
|
||||
.new-record form input[type="submit"] {
|
||||
margin-right: 1ch;
|
||||
margin-top: .75rem;
|
||||
}
|
||||
|
||||
.new-record header {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.new-record form .preview {
|
||||
margin: .5rem 0;
|
||||
border: 1px solid rgb(var(--color-primary));
|
||||
}
|
||||
|
||||
.new-record form .preview p:first-of-type {
|
||||
margin-top: .5rem;
|
||||
}
|
||||
|
||||
.new-record form .preview p:last-of-type {
|
||||
margin-bottom: .5rem;
|
||||
}
|
||||
|
||||
.new-record form .preview p {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.new-record form .preview code {
|
||||
padding: 0 .5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.new-record form .preview img {
|
||||
padding: 0 .25rem;
|
||||
border-right: 1px solid #1b841b;
|
||||
}
|
||||
|
||||
.new-record form .preview .addition {
|
||||
background: #d9fbd9;
|
||||
}
|
||||
|
||||
.new-record form .preview h3 {
|
||||
margin: 0;
|
||||
padding: .0rem .5rem 0 .5rem;;
|
||||
font-size: 1rem;
|
||||
font-weight: normal;
|
||||
background: rgb(var(--color-primary));
|
||||
color: var(--color-contrast)
|
||||
}
|
65
src/database.rs
Normal file
65
src/database.rs
Normal file
|
@ -0,0 +1,65 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use crate::resources::zone::ZoneModel;
|
||||
|
||||
pub trait Db: ZoneModel + Send + Sync {}
|
||||
pub type BoxedDb = Arc<dyn Db>;
|
||||
|
||||
impl Db for sqlite::SqliteDB {}
|
||||
|
||||
pub mod sqlite {
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SqliteDB {
|
||||
pub pool: bb8::Pool<SqliteConnManager>
|
||||
}
|
||||
|
||||
impl SqliteDB {
|
||||
pub async fn new(path: PathBuf) -> Self {
|
||||
let pool = bb8::Pool::builder()
|
||||
.build(SqliteConnManager::new(path))
|
||||
.await
|
||||
.expect("Unable to connect to database");
|
||||
|
||||
SqliteDB {
|
||||
pool,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SqliteConnManager {
|
||||
path: Arc<PathBuf>
|
||||
}
|
||||
|
||||
impl SqliteConnManager {
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
SqliteConnManager {
|
||||
path: Arc::new(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl bb8::ManageConnection for SqliteConnManager {
|
||||
type Connection = rusqlite::Connection;
|
||||
type Error = rusqlite::Error;
|
||||
|
||||
async fn connect(&self) -> Result<Self::Connection, Self::Error> {
|
||||
let opt = self.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
rusqlite::Connection::open(opt.path.as_ref())
|
||||
}).await.unwrap()
|
||||
}
|
||||
|
||||
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
|
||||
tokio::task::block_in_place(|| conn.execute_batch(""))
|
||||
}
|
||||
|
||||
fn has_broken(&self, _conn: &mut Self::Connection) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,271 +0,0 @@
|
|||
use trust_dns_proto::DnsHandle;
|
||||
use trust_dns_client::client::ClientHandle;
|
||||
use trust_dns_client::rr::{DNSClass, RecordType};
|
||||
use trust_dns_client::op::{UpdateMessage, OpCode, MessageType, Message, Query, ResponseCode, Edns};
|
||||
use trust_dns_client::error::ClientError;
|
||||
|
||||
use super::{Name, Record, RData};
|
||||
use super::client::{ClientResponse, DnsClient};
|
||||
use super::connector::{RecordConnector, ZoneConnector, ConnectorError, ConnectorResult};
|
||||
|
||||
|
||||
const MAX_PAYLOAD_LEN: u16 = 1232;
|
||||
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DnsConnectorError {
|
||||
ClientError(ClientError),
|
||||
ResponceNotOk {
|
||||
code: ResponseCode,
|
||||
zone: Name,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct DnsConnectorClient {
|
||||
client: DnsClient
|
||||
}
|
||||
|
||||
impl DnsConnectorClient {
|
||||
pub fn new(client: DnsClient) -> Self {
|
||||
DnsConnectorClient {
|
||||
client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConnectorError for DnsConnectorError {
|
||||
fn zone_name(&self) -> Option<Name> {
|
||||
if let DnsConnectorError::ResponceNotOk { code: _code, zone } = self {
|
||||
Some(zone.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn is_proto_error(&self) -> bool {
|
||||
return matches!(self, DnsConnectorError::ClientError(_));
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DnsConnectorError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DnsConnectorError::ClientError(e) => {
|
||||
write!(f, "DNS client error: {}", e)
|
||||
},
|
||||
DnsConnectorError::ResponceNotOk { code, zone } => {
|
||||
write!(f, "Query for zone \"{}\" failed with code \"{}\"", zone, code)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fn set_edns(message: &mut Message) {
|
||||
let edns = message.extensions_mut().get_or_insert_with(Edns::new);
|
||||
edns.set_max_payload(MAX_PAYLOAD_LEN);
|
||||
edns.set_version(0);
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RecordConnector for DnsConnectorClient {
|
||||
//type Error = DnsConnectorError;
|
||||
|
||||
async fn get_records(&mut self, zone: Name, class: DNSClass) -> ConnectorResult<Vec<Record>>
|
||||
{
|
||||
let response = {
|
||||
let query = self.client.query(zone.clone(), class, RecordType::AXFR);
|
||||
match query.await.map_err(|e| Box::new(DnsConnectorError::ClientError(e))) {
|
||||
Err(e) => return Err(e),
|
||||
Ok(v) => v,
|
||||
}
|
||||
};
|
||||
|
||||
if response.response_code() != ResponseCode::NoError {
|
||||
return Err(Box::new(DnsConnectorError::ResponceNotOk {
|
||||
code: response.response_code(),
|
||||
zone: zone,
|
||||
}));
|
||||
}
|
||||
|
||||
let answers = response.answers();
|
||||
let mut records: Vec<_> = answers.to_vec().into_iter()
|
||||
.filter(|record| record.data().is_some() && !matches!(record.data().unwrap(), RData::NULL { .. } | RData::DNSSEC(_)))
|
||||
.collect();
|
||||
|
||||
// AXFR response ends with SOA, we remove it so it is not doubled in the response.
|
||||
records.pop();
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
async fn add_records(&mut self, zone: Name, class: DNSClass, new_records: Vec<Record>) -> ConnectorResult<()>
|
||||
{
|
||||
// Taken from trust_dns_client::op::update_message::append
|
||||
// The original function can not be used as is because it takes a RecordSet and not a Record list
|
||||
|
||||
let mut zone_query = Query::new();
|
||||
zone_query.set_name(zone.clone())
|
||||
.set_query_class(class)
|
||||
.set_query_type(RecordType::SOA);
|
||||
|
||||
let mut message = Message::new();
|
||||
|
||||
// TODO: set random / time based id
|
||||
message
|
||||
.set_id(0)
|
||||
.set_message_type(MessageType::Query)
|
||||
.set_op_code(OpCode::Update)
|
||||
.set_recursion_desired(false);
|
||||
message.add_zone(zone_query);
|
||||
message.add_updates(new_records);
|
||||
|
||||
set_edns(&mut message);
|
||||
|
||||
let response = match ClientResponse(self.client.send(message)).await.map_err(|e| Box::new(DnsConnectorError::ClientError(e))) {
|
||||
Err(e) => return Err(e),
|
||||
Ok(v) => v,
|
||||
};
|
||||
|
||||
if response.response_code() != ResponseCode::NoError {
|
||||
return Err(Box::new(DnsConnectorError::ResponceNotOk {
|
||||
code: response.response_code(),
|
||||
zone: zone,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_records(&mut self, zone: Name, class: DNSClass, old_records: Vec<Record>, new_records: Vec<Record>) -> ConnectorResult<()>
|
||||
{
|
||||
// Taken from trust_dns_client::op::update_message::compare_and_swap
|
||||
// The original function can not be used as is because it takes a RecordSet and not a Record list
|
||||
|
||||
// for updates, the query section is used for the zone
|
||||
let mut zone_query = Query::new();
|
||||
zone_query.set_name(zone.clone())
|
||||
.set_query_class(class)
|
||||
.set_query_type(RecordType::SOA);
|
||||
|
||||
let mut message: Message = Message::new();
|
||||
|
||||
// build the message
|
||||
// TODO: set random / time based id
|
||||
message
|
||||
.set_id(0)
|
||||
.set_message_type(MessageType::Query)
|
||||
.set_op_code(OpCode::Update)
|
||||
.set_recursion_desired(false);
|
||||
message.add_zone(zone_query);
|
||||
|
||||
// make sure the record is what is expected
|
||||
let mut prerequisite = old_records.clone();
|
||||
for record in prerequisite.iter_mut() {
|
||||
record.set_ttl(0);
|
||||
}
|
||||
message.add_pre_requisites(prerequisite);
|
||||
|
||||
// add the delete for the old record
|
||||
let mut delete = old_records;
|
||||
for record in delete.iter_mut() {
|
||||
// the class must be none for delete
|
||||
record.set_dns_class(DNSClass::NONE);
|
||||
// the TTL should be 0
|
||||
record.set_ttl(0);
|
||||
}
|
||||
message.add_updates(delete);
|
||||
|
||||
// insert the new record...
|
||||
message.add_updates(new_records);
|
||||
|
||||
// Extended dns
|
||||
set_edns(&mut message);
|
||||
|
||||
let response = match ClientResponse(self.client.send(message)).await.map_err(|e| Box::new(DnsConnectorError::ClientError(e))) {
|
||||
Err(e) => return Err(e),
|
||||
Ok(v) => v,
|
||||
};
|
||||
|
||||
if response.response_code() != ResponseCode::NoError {
|
||||
return Err(Box::new(DnsConnectorError::ResponceNotOk {
|
||||
code: response.response_code(),
|
||||
zone: zone,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_records(&mut self, zone: Name, class: DNSClass, records: Vec<Record>) -> ConnectorResult<()>
|
||||
{
|
||||
// for updates, the query section is used for the zone
|
||||
let mut zone_query = Query::new();
|
||||
zone_query.set_name(zone.clone())
|
||||
.set_query_class(class)
|
||||
.set_query_type(RecordType::SOA);
|
||||
|
||||
let mut message: Message = Message::new();
|
||||
|
||||
// build the message
|
||||
// TODO: set random / time based id
|
||||
message
|
||||
.set_id(0)
|
||||
.set_message_type(MessageType::Query)
|
||||
.set_op_code(OpCode::Update)
|
||||
.set_recursion_desired(false);
|
||||
message.add_zone(zone_query);
|
||||
|
||||
let mut delete = records;
|
||||
for record in delete.iter_mut() {
|
||||
// the class must be none for delete
|
||||
record.set_dns_class(DNSClass::NONE);
|
||||
// the TTL should be 0
|
||||
record.set_ttl(0);
|
||||
}
|
||||
message.add_updates(delete);
|
||||
|
||||
// Extended dns
|
||||
set_edns(&mut message);
|
||||
|
||||
let response = match ClientResponse(self.client.send(message)).await.map_err(|e| Box::new(DnsConnectorError::ClientError(e))) {
|
||||
Err(e) => return Err(e),
|
||||
Ok(v) => v,
|
||||
};
|
||||
|
||||
if response.response_code() != ResponseCode::NoError {
|
||||
return Err(Box::new(DnsConnectorError::ResponceNotOk {
|
||||
code: response.response_code(),
|
||||
zone: zone,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[async_trait]
|
||||
impl ZoneConnector for DnsConnectorClient {
|
||||
async fn zone_exists(&mut self, zone: Name, class: DNSClass) -> ConnectorResult<()>
|
||||
{
|
||||
let response = {
|
||||
info!("Querying SOA for name {}", zone);
|
||||
let query = self.client.query(zone.clone(), class, RecordType::SOA);
|
||||
match query.await.map_err(|e| Box::new(DnsConnectorError::ClientError(e))) {
|
||||
Err(e) => return Err(e),
|
||||
Ok(v) => v,
|
||||
}
|
||||
};
|
||||
|
||||
if response.response_code() != ResponseCode::NoError {
|
||||
return Err(Box::new(DnsConnectorError::ResponceNotOk {
|
||||
code: response.response_code(),
|
||||
zone: zone,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
}
|
577
src/dns/dns_driver.rs
Normal file
577
src/dns/dns_driver.rs
Normal file
|
@ -0,0 +1,577 @@
|
|||
use std::io;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
use std::sync::Arc;
|
||||
|
||||
use domain::base::iana::{Opcode, Rcode};
|
||||
use domain::base::{message_builder, name, wire};
|
||||
use domain::base::{MessageBuilder, Name, Rtype};
|
||||
use domain::net::client::{tsig, stream};
|
||||
use domain::tsig::{Algorithm, Key, KeyName};
|
||||
use domain::net::client::request::{self, RequestMessage, RequestMessageMulti, SendRequest, SendRequestMulti};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
use crate::resources::dns::internal;
|
||||
use crate::proto;
|
||||
use crate::errors::Error;
|
||||
use super::{RecordDriver, ZoneDriver, DnsDriverError};
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
pub struct DnsDriverConfig {
|
||||
pub address: SocketAddr,
|
||||
pub tsig: Option<TsigConfig>
|
||||
}
|
||||
|
||||
pub struct TsigConfig {
|
||||
pub key_name: KeyName,
|
||||
pub secret: Vec<u8>,
|
||||
pub algorithm: Algorithm
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DnsDriver {
|
||||
pub addr: SocketAddr,
|
||||
pub tsig_key: Option<Arc<Key>>,
|
||||
}
|
||||
|
||||
type TsigDnsClient = tsig::Connection<stream::Connection<
|
||||
tsig::RequestMessage<request::RequestMessage<Vec<u8>>, Arc<Key>>,
|
||||
tsig::RequestMessage<request::RequestMessageMulti<Vec<u8>>, Arc<Key>>
|
||||
>, Arc<Key>>;
|
||||
|
||||
impl DnsDriver {
|
||||
pub fn from_config(config: DnsDriverConfig) -> Self {
|
||||
let key = config.tsig.map(|tsig_config| {
|
||||
Arc::new(
|
||||
Key::new(
|
||||
tsig_config.algorithm,
|
||||
&tsig_config.secret,
|
||||
tsig_config.key_name,
|
||||
None,
|
||||
None
|
||||
).expect("Failed to build key"),
|
||||
)
|
||||
});
|
||||
|
||||
Self {
|
||||
addr: config.address,
|
||||
tsig_key: key,
|
||||
}
|
||||
}
|
||||
|
||||
async fn client<Req, ReqMulti>(&self) -> Result<stream::Connection<Req, ReqMulti>, DnsDriverError>
|
||||
where
|
||||
Req: request::ComposeRequest + Send + Sync + 'static,
|
||||
ReqMulti: request::ComposeRequestMulti + Send + Sync + 'static,
|
||||
{
|
||||
let mut stream_config = stream::Config::default();
|
||||
stream_config.set_response_timeout(
|
||||
Duration::from_millis(100),
|
||||
);
|
||||
let tcp_connect = TcpStream::connect(self.addr).await?;
|
||||
|
||||
let (tcp_conn, transport) = stream::Connection::with_config(
|
||||
tcp_connect, stream_config
|
||||
);
|
||||
|
||||
tokio::spawn(transport.run());
|
||||
|
||||
Ok(tcp_conn)
|
||||
}
|
||||
|
||||
async fn tsig_client(&self) -> Result<Option<TsigDnsClient>, DnsDriverError>
|
||||
{
|
||||
if let Some(ref key) = self.tsig_key {
|
||||
let conn = self.client().await?;
|
||||
Ok(Some(tsig::Connection::new(key.clone(), conn)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ZoneDriver for DnsDriver {
|
||||
async fn zone_exists(&self, zone: &str) -> Result<(), DnsDriverError> {
|
||||
let client = self.client::<_, RequestMessageMulti<Vec<u8>>>().await?;
|
||||
|
||||
let mut msg = MessageBuilder::new_vec().question();
|
||||
msg.push((
|
||||
Name::vec_from_str(zone)?,
|
||||
Rtype::SOA,
|
||||
))?;
|
||||
|
||||
let req = RequestMessage::new(msg)?;
|
||||
|
||||
let res = SendRequest::send_request(&client, req)
|
||||
.get_response()
|
||||
.await?;
|
||||
|
||||
let rcode = res.header().rcode();
|
||||
|
||||
match rcode {
|
||||
Rcode::NOERROR => Ok(()),
|
||||
Rcode::NXDOMAIN | Rcode::REFUSED => Err(DnsDriverError::ZoneNotFound {
|
||||
name: zone.to_string(),
|
||||
}),
|
||||
rcode => Err(DnsDriverError::ServerError {
|
||||
rcode: rcode.to_string(),
|
||||
name: zone.to_string(),
|
||||
qtype: Rtype::SOA.to_string()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RecordDriver for DnsDriver {
|
||||
/// ------------- AXFR -------------
|
||||
|
||||
async fn get_records(&self, zone: &str) -> Result<internal::RecordList, DnsDriverError> {
|
||||
let mut msg = MessageBuilder::new_vec();
|
||||
msg.header_mut().set_ad(true);
|
||||
|
||||
let mut msg = msg.question();
|
||||
msg.push((
|
||||
Name::vec_from_str(zone)?,
|
||||
Rtype::AXFR,
|
||||
))?;
|
||||
|
||||
let req = RequestMessageMulti::new(msg)?;
|
||||
|
||||
let tsig_client = self.tsig_client().await?;
|
||||
|
||||
let mut request = if let Some(client) = tsig_client {
|
||||
SendRequestMulti::send_request(&client, req)
|
||||
} else {
|
||||
let client = self.client::<RequestMessage<Vec<u8>>,_>().await?;
|
||||
SendRequestMulti::send_request(&client, req)
|
||||
};
|
||||
|
||||
let mut records = Vec::new();
|
||||
|
||||
while let Some(reply) = request.get_response().await? {
|
||||
let rcode = reply.header().rcode();
|
||||
|
||||
if rcode != Rcode::NOERROR {
|
||||
return Err(DnsDriverError::ServerError {
|
||||
rcode: rcode.to_string(),
|
||||
name: zone.to_string(),
|
||||
qtype: Rtype::AXFR.to_string()
|
||||
});
|
||||
}
|
||||
|
||||
let answer = reply.answer()?;
|
||||
|
||||
for record in answer.limit_to::<proto::dns::ParsedRData<_, _>>() {
|
||||
let record = record?;
|
||||
records.push(record.into())
|
||||
}
|
||||
}
|
||||
|
||||
// AXFR response ends with SOA, we remove it so it is not doubled in the response.
|
||||
records.pop();
|
||||
|
||||
Ok(internal::RecordList { records })
|
||||
}
|
||||
|
||||
/// ------------- Dynamic Update - RFC 2136 -------------
|
||||
///
|
||||
/// 2 - Update Message Format
|
||||
/// +---------------------+
|
||||
/// | Header |
|
||||
/// +---------------------+
|
||||
/// | Zone | specifies the zone to be updated (RFC1035 Question)
|
||||
/// +---------------------+
|
||||
/// | Prerequisite | RRs or RRsets which must (not) preexist (RFC1035 Answer)
|
||||
/// +---------------------+
|
||||
/// | Update | RRs or RRsets to be added or deleted (RFC1035 Authority)
|
||||
/// +---------------------+
|
||||
/// | Additional Data | additional data
|
||||
/// +---------------------+
|
||||
/// 2.2 - Message Header
|
||||
///
|
||||
/// OPCODE is set to UPDATE.
|
||||
/// UPDATE uses only one flag bit (QR).
|
||||
///
|
||||
/// 2.3 - Zone Section
|
||||
///
|
||||
/// The ZNAME is the zone name, the ZTYPE must be SOA, and the ZCLASS is
|
||||
/// the zone's class.
|
||||
///
|
||||
/// 3.2.4 - Table Of Metavalues Used In Prerequisite Section
|
||||
///
|
||||
/// TTL must be specified as zero (0) for all prerequisite
|
||||
///
|
||||
/// CLASS TYPE RDATA Meaning
|
||||
/// ------------------------------------------------------------
|
||||
/// ANY ANY empty Name is in use
|
||||
/// ANY rrset empty RRset exists (value independent)
|
||||
/// NONE ANY empty Name is not in use
|
||||
/// NONE rrset empty RRset does not exist
|
||||
/// zone rrset rr RRset exists (value dependent) - Match against ALL RR in a RRset!!
|
||||
///
|
||||
/// 3.4.2.6 - Table Of Metavalues Used In Update Section
|
||||
///
|
||||
/// CLASS TYPE RDATA Meaning
|
||||
/// ---------------------------------------------------------
|
||||
/// ANY ANY empty Delete all RRsets from a name - TTL must be specified as zero (0)
|
||||
/// ANY rrset empty Delete an RRset - TTL must be specified as zero (0)
|
||||
/// NONE rrset rr Delete an RR from an RRset - TTL must be specified as zero (0)
|
||||
/// zone rrset rr Add to an RRset
|
||||
|
||||
|
||||
async fn add_records(&self, zone: &str, new_records: internal::RecordList) -> Result<(), DnsDriverError> {
|
||||
let mut msg = MessageBuilder::new_vec();
|
||||
msg.header_mut().set_opcode(Opcode::UPDATE);
|
||||
|
||||
let mut msg = msg.question();
|
||||
msg.push((
|
||||
Name::vec_from_str(zone)?,
|
||||
Rtype::SOA,
|
||||
))?;
|
||||
|
||||
let mut msg = msg.authority();
|
||||
|
||||
for record in new_records.records {
|
||||
let record = proto::dns::RecordImpl::try_from(record)?;
|
||||
msg.push(record)?;
|
||||
}
|
||||
|
||||
let req = RequestMessage::new(msg)?;
|
||||
|
||||
let tsig_client = self.tsig_client().await?;
|
||||
|
||||
let mut request = if let Some(client) = tsig_client {
|
||||
SendRequest::send_request(&client, req)
|
||||
} else {
|
||||
let client = self.client::<_, RequestMessageMulti<Vec<u8>>>().await?;
|
||||
SendRequest::send_request(&client, req)
|
||||
};
|
||||
|
||||
let reply = request.get_response().await?;
|
||||
let rcode = reply.header().rcode();
|
||||
|
||||
if rcode != Rcode::NOERROR {
|
||||
Err(DnsDriverError::ServerError {
|
||||
rcode: rcode.to_string(),
|
||||
name: zone.to_string(),
|
||||
qtype: "UPDATE".to_string(),
|
||||
})
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for DnsDriverError {
|
||||
fn from(value: io::Error) -> Self {
|
||||
DnsDriverError::ConnectionError { reason: Box::new(value) }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<request::Error> for DnsDriverError {
|
||||
fn from(value: request::Error) -> Self {
|
||||
DnsDriverError::ConnectionError { reason: Box::new(value) }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<message_builder::PushError> for DnsDriverError {
|
||||
fn from(value: message_builder::PushError) -> Self {
|
||||
DnsDriverError::OperationError { reason: Box::new(value) }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<name::FromStrError> for DnsDriverError {
|
||||
fn from(value: name::FromStrError) -> Self {
|
||||
DnsDriverError::OperationError { reason: Box::new(value) }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<wire::ParseError> for DnsDriverError {
|
||||
fn from(value: wire::ParseError) -> Self {
|
||||
DnsDriverError::OperationError { reason: Box::new(value) }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Error> for DnsDriverError {
|
||||
fn from(value: Error) -> Self {
|
||||
DnsDriverError::OperationError { reason: Box::new(value) }
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
use trust_dns_proto::DnsHandle;
|
||||
use trust_dns_client::client::ClientHandle;
|
||||
use trust_dns_client::rr::{DNSClass, RecordType};
|
||||
use trust_dns_client::op::{UpdateMessage, OpCode, MessageType, Message, Query, ResponseCode, Edns};
|
||||
use trust_dns_client::error::ClientError;
|
||||
|
||||
use super::{Name, Record, RData};
|
||||
use super::client::{ClientResponse, DnsClient};
|
||||
use super::connector::{RecordConnector, ZoneConnector, ConnectorError, ConnectorResult};
|
||||
|
||||
|
||||
const MAX_PAYLOAD_LEN: u16 = 1232;
|
||||
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DnsConnectorError {
|
||||
ClientError(ClientError),
|
||||
ResponceNotOk {
|
||||
code: ResponseCode,
|
||||
zone: Name,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct DnsConnectorClient {
|
||||
client: DnsClient
|
||||
}
|
||||
|
||||
impl DnsConnectorClient {
|
||||
pub fn new(client: DnsClient) -> Self {
|
||||
DnsConnectorClient {
|
||||
client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ConnectorError for DnsConnectorError {
|
||||
fn zone_name(&self) -> Option<Name> {
|
||||
if let DnsConnectorError::ResponceNotOk { code: _code, zone } = self {
|
||||
Some(zone.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn is_proto_error(&self) -> bool {
|
||||
return matches!(self, DnsConnectorError::ClientError(_));
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DnsConnectorError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
DnsConnectorError::ClientError(e) => {
|
||||
write!(f, "DNS client error: {}", e)
|
||||
},
|
||||
DnsConnectorError::ResponceNotOk { code, zone } => {
|
||||
write!(f, "Query for zone \"{}\" failed with code \"{}\"", zone, code)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
fn set_edns(message: &mut Message) {
|
||||
let edns = message.extensions_mut().get_or_insert_with(Edns::new);
|
||||
edns.set_max_payload(MAX_PAYLOAD_LEN);
|
||||
edns.set_version(0);
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RecordConnector for DnsConnectorClient {
|
||||
//type Error = DnsConnectorError;
|
||||
|
||||
async fn get_records(&mut self, zone: Name, class: DNSClass) -> ConnectorResult<Vec<Record>>
|
||||
{
|
||||
let response = {
|
||||
let query = self.client.query(zone.clone(), class, RecordType::AXFR);
|
||||
match query.await.map_err(|e| Box::new(DnsConnectorError::ClientError(e))) {
|
||||
Err(e) => return Err(e),
|
||||
Ok(v) => v,
|
||||
}
|
||||
};
|
||||
|
||||
if response.response_code() != ResponseCode::NoError {
|
||||
return Err(Box::new(DnsConnectorError::ResponceNotOk {
|
||||
code: response.response_code(),
|
||||
zone: zone,
|
||||
}));
|
||||
}
|
||||
|
||||
let answers = response.answers();
|
||||
let mut records: Vec<_> = answers.to_vec().into_iter()
|
||||
.filter(|record| record.data().is_some() && !matches!(record.data().unwrap(), RData::NULL { .. } | RData::DNSSEC(_)))
|
||||
.collect();
|
||||
|
||||
// AXFR response ends with SOA, we remove it so it is not doubled in the response.
|
||||
records.pop();
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
async fn add_records(&mut self, zone: Name, class: DNSClass, new_records: Vec<Record>) -> ConnectorResult<()>
|
||||
{
|
||||
// Taken from trust_dns_client::op::update_message::append
|
||||
// The original function can not be used as is because it takes a RecordSet and not a Record list
|
||||
|
||||
let mut zone_query = Query::new();
|
||||
zone_query.set_name(zone.clone())
|
||||
.set_query_class(class)
|
||||
.set_query_type(RecordType::SOA);
|
||||
|
||||
let mut message = Message::new();
|
||||
|
||||
// TODO: set random / time based id
|
||||
message
|
||||
.set_id(0)
|
||||
.set_message_type(MessageType::Query)
|
||||
.set_op_code(OpCode::Update)
|
||||
.set_recursion_desired(false);
|
||||
message.add_zone(zone_query);
|
||||
message.add_updates(new_records);
|
||||
|
||||
set_edns(&mut message);
|
||||
|
||||
let response = match ClientResponse(self.client.send(message)).await.map_err(|e| Box::new(DnsConnectorError::ClientError(e))) {
|
||||
Err(e) => return Err(e),
|
||||
Ok(v) => v,
|
||||
};
|
||||
|
||||
if response.response_code() != ResponseCode::NoError {
|
||||
return Err(Box::new(DnsConnectorError::ResponceNotOk {
|
||||
code: response.response_code(),
|
||||
zone: zone,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn update_records(&mut self, zone: Name, class: DNSClass, old_records: Vec<Record>, new_records: Vec<Record>) -> ConnectorResult<()>
|
||||
{
|
||||
// Taken from trust_dns_client::op::update_message::compare_and_swap
|
||||
// The original function can not be used as is because it takes a RecordSet and not a Record list
|
||||
|
||||
// for updates, the query section is used for the zone
|
||||
let mut zone_query = Query::new();
|
||||
zone_query.set_name(zone.clone())
|
||||
.set_query_class(class)
|
||||
.set_query_type(RecordType::SOA);
|
||||
|
||||
let mut message: Message = Message::new();
|
||||
|
||||
// build the message
|
||||
// TODO: set random / time based id
|
||||
message
|
||||
.set_id(0)
|
||||
.set_message_type(MessageType::Query)
|
||||
.set_op_code(OpCode::Update)
|
||||
.set_recursion_desired(false);
|
||||
message.add_zone(zone_query);
|
||||
|
||||
// make sure the record is what is expected
|
||||
let mut prerequisite = old_records.clone();
|
||||
for record in prerequisite.iter_mut() {
|
||||
record.set_ttl(0);
|
||||
}
|
||||
message.add_pre_requisites(prerequisite);
|
||||
|
||||
// add the delete for the old record
|
||||
let mut delete = old_records;
|
||||
for record in delete.iter_mut() {
|
||||
// the class must be none for delete
|
||||
record.set_dns_class(DNSClass::NONE);
|
||||
// the TTL should be 0
|
||||
record.set_ttl(0);
|
||||
}
|
||||
message.add_updates(delete);
|
||||
|
||||
// insert the new record...
|
||||
message.add_updates(new_records);
|
||||
|
||||
// Extended dns
|
||||
set_edns(&mut message);
|
||||
|
||||
let response = match ClientResponse(self.client.send(message)).await.map_err(|e| Box::new(DnsConnectorError::ClientError(e))) {
|
||||
Err(e) => return Err(e),
|
||||
Ok(v) => v,
|
||||
};
|
||||
|
||||
if response.response_code() != ResponseCode::NoError {
|
||||
return Err(Box::new(DnsConnectorError::ResponceNotOk {
|
||||
code: response.response_code(),
|
||||
zone: zone,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_records(&mut self, zone: Name, class: DNSClass, records: Vec<Record>) -> ConnectorResult<()>
|
||||
{
|
||||
// for updates, the query section is used for the zone
|
||||
let mut zone_query = Query::new();
|
||||
zone_query.set_name(zone.clone())
|
||||
.set_query_class(class)
|
||||
.set_query_type(RecordType::SOA);
|
||||
|
||||
let mut message: Message = Message::new();
|
||||
|
||||
// build the message
|
||||
// TODO: set random / time based id
|
||||
message
|
||||
.set_id(0)
|
||||
.set_message_type(MessageType::Query)
|
||||
.set_op_code(OpCode::Update)
|
||||
.set_recursion_desired(false);
|
||||
message.add_zone(zone_query);
|
||||
|
||||
let mut delete = records;
|
||||
for record in delete.iter_mut() {
|
||||
// the class must be none for delete
|
||||
record.set_dns_class(DNSClass::NONE);
|
||||
// the TTL should be 0
|
||||
record.set_ttl(0);
|
||||
}
|
||||
message.add_updates(delete);
|
||||
|
||||
// Extended dns
|
||||
set_edns(&mut message);
|
||||
|
||||
let response = match ClientResponse(self.client.send(message)).await.map_err(|e| Box::new(DnsConnectorError::ClientError(e))) {
|
||||
Err(e) => return Err(e),
|
||||
Ok(v) => v,
|
||||
};
|
||||
|
||||
if response.response_code() != ResponseCode::NoError {
|
||||
return Err(Box::new(DnsConnectorError::ResponceNotOk {
|
||||
code: response.response_code(),
|
||||
zone: zone,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[async_trait]
|
||||
impl ZoneConnector for DnsConnectorClient {
|
||||
async fn zone_exists(&mut self, zone: Name, class: DNSClass) -> ConnectorResult<()>
|
||||
{
|
||||
let response = {
|
||||
info!("Querying SOA for name {}", zone);
|
||||
let query = self.client.query(zone.clone(), class, RecordType::SOA);
|
||||
match query.await.map_err(|e| Box::new(DnsConnectorError::ClientError(e))) {
|
||||
Err(e) => return Err(e),
|
||||
Ok(v) => v,
|
||||
}
|
||||
};
|
||||
|
||||
if response.response_code() != ResponseCode::NoError {
|
||||
return Err(Box::new(DnsConnectorError::ResponceNotOk {
|
||||
code: response.response_code(),
|
||||
zone: zone,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
}
|
||||
*/
|
|
@ -1,3 +1,38 @@
|
|||
pub mod dns_driver;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::resources::dns::internal;
|
||||
|
||||
pub type BoxedZoneDriver = Arc<dyn ZoneDriver>;
|
||||
pub type BoxedRecordDriver = Arc<dyn RecordDriver>;
|
||||
pub enum DnsDriverError {
|
||||
ConnectionError { reason: Box<dyn std::error::Error> },
|
||||
OperationError { reason: Box<dyn std::error::Error> },
|
||||
ServerError { rcode: String, name: String, qtype: String },
|
||||
ZoneNotFound { name: String },
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ZoneDriver: Send + Sync {
|
||||
// get_zones
|
||||
// add_zone
|
||||
// delete_zone
|
||||
async fn zone_exists(&self, zone: &str) -> Result<(), DnsDriverError>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait RecordDriver: Send + Sync {
|
||||
async fn get_records(&self, zone: &str) -> Result<internal::RecordList, DnsDriverError>;
|
||||
async fn add_records(&self, zone: &str, new_records: internal::RecordList) -> Result<(), DnsDriverError>;
|
||||
//async fn update_records(&mut self, zone: dns::Name, class: dns::DNSClass, old_records: Vec<dns::Record>, new_records: Vec<dns::Record>) -> ConnectorResult<()>;
|
||||
//async fn delete_records(&mut self, zone: dns::Name, class: dns::DNSClass, records: Vec<dns::Record>) -> ConnectorResult<()>;
|
||||
|
||||
}
|
||||
|
||||
/*
|
||||
pub mod client;
|
||||
pub mod dns_connector;
|
||||
pub mod connector;
|
||||
|
@ -53,3 +88,4 @@ impl<'r> FromRequest<'r> for Box<dyn ZoneConnector> {
|
|||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
|
455
src/errors.rs
Normal file
455
src/errors.rs
Normal file
|
@ -0,0 +1,455 @@
|
|||
use std::fmt;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::http::{self, StatusCode};
|
||||
use axum::response::{AppendHeaders, IntoResponse, Response};
|
||||
|
||||
use axum::Json;
|
||||
use serde::{Serialize, Serializer};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::dns::DnsDriverError;
|
||||
use crate::resources::dns::external::rdata::RDataValidationError;
|
||||
use crate::resources::dns::external::record::{RecordError, RecordValidationError};
|
||||
use crate::resources::dns::friendly::InputDataError;
|
||||
use crate::resources::zone::ZoneError;
|
||||
use crate::validation::{DomainValidationError, TxtParseError};
|
||||
use crate::template::TemplateError;
|
||||
use crate::proto::dns::ProtoDnsError;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Error {
|
||||
#[serde(skip)]
|
||||
cause: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none", serialize_with = "serialize_status")]
|
||||
status: Option<StatusCode>,
|
||||
code: String,
|
||||
description: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
details: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
errors: Option<Vec<Error>>,
|
||||
}
|
||||
|
||||
pub fn serialize_status<S>(status: &Option<StatusCode>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where S: Serializer
|
||||
{
|
||||
if let Some(status) = status {
|
||||
serializer.serialize_u16(status.as_u16())
|
||||
} else {
|
||||
serializer.serialize_unit()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error_map(errors: Vec<Error>) -> HashMap<String, Error> {
|
||||
errors.into_iter()
|
||||
.filter_map(|mut error|
|
||||
error.path.take().map(|path| (path, error))
|
||||
).collect()
|
||||
}
|
||||
|
||||
impl Error {
|
||||
pub fn new(code: &str, description: &str) -> Self {
|
||||
Error {
|
||||
cause: None,
|
||||
status: None,
|
||||
code: code.into(),
|
||||
description: description.into(),
|
||||
details: None,
|
||||
path: None,
|
||||
errors: None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_cause(self, cause: &str) -> Self {
|
||||
Self {
|
||||
cause: Some(cause.into()),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_status(self, status: StatusCode) -> Self {
|
||||
Self {
|
||||
status: Some(status),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_path(self, path: &str) -> Self {
|
||||
if let Some(current_path) = self.path {
|
||||
Self {
|
||||
path: Some(format!("{path}{current_path}")),
|
||||
..self
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
path: Some(path.into()),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_details<T: Serialize> (self, details: T) -> Self {
|
||||
let mut new_details = serde_json::to_value(details).expect("failed to convert details to serde_json::Value");
|
||||
let details = self.details;
|
||||
|
||||
// append new details to existing details
|
||||
if let Some(mut details) = details {
|
||||
if let Some(object) = details.as_object_mut() {
|
||||
if let Some(new_object) = new_details.as_object_mut() {
|
||||
object.append(new_object);
|
||||
|
||||
return Self {
|
||||
details: Some(details),
|
||||
..self
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
details: Some(new_details),
|
||||
..self
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
pub fn with_suberrors(self, mut errors: Vec<Error>) -> Self {
|
||||
for error in &mut errors {
|
||||
error.status = None;
|
||||
}
|
||||
|
||||
Self {
|
||||
errors: Some(errors),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn override_status(self, status: StatusCode) -> Self {
|
||||
if self.status.is_some() {
|
||||
self.with_status(status)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.description)?;
|
||||
|
||||
if let Some(cause) = &self.cause {
|
||||
write!(f, ": {}", cause)?;
|
||||
}
|
||||
|
||||
if self.status.is_some() || self.details.is_some() {
|
||||
write!(f, " (")?;
|
||||
}
|
||||
|
||||
if let Some(status) = &self.status {
|
||||
write!(f, "status = {}", status)?;
|
||||
}
|
||||
|
||||
if let Some(details) = &self.details {
|
||||
if self.status.is_some() {
|
||||
write!(f, ", ")?;
|
||||
}
|
||||
write!(f, "details = {}", serde_json::to_string(details).expect("Failed to serialize error details"))?;
|
||||
}
|
||||
|
||||
if self.status.is_some() || self.details.is_some() {
|
||||
write!(f, ")")?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
impl IntoResponse for Error {
|
||||
fn into_response(self) -> Response {
|
||||
if let Some(status) = self.status {
|
||||
(status, Json(self)).into_response()
|
||||
} else {
|
||||
eprintln!("{}", self);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
AppendHeaders([
|
||||
(http::header::CONTENT_TYPE, "application/json")
|
||||
]),
|
||||
r#"{"status": 500,"description":"Internal server error","code":"internal"}"#
|
||||
).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<bb8::RunError<rusqlite::Error>> for Error {
|
||||
fn from(value: bb8::RunError<rusqlite::Error>) -> Self {
|
||||
Error::new("db:pool", "Failed to get database connection from pool")
|
||||
.with_cause(&value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for Error {
|
||||
fn from(value: rusqlite::Error) -> Self {
|
||||
Error::new("db:sqlite", "Sqlite failure")
|
||||
.with_cause(&format!("{:?}", value))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ZoneError> for Error {
|
||||
fn from(value: ZoneError) -> Self {
|
||||
match value {
|
||||
ZoneError::ZoneConflict { name } => {
|
||||
Error::new("zone:conflict", "Zone {zone_name} already exists")
|
||||
.with_details(json!({
|
||||
"zone_name": name
|
||||
}))
|
||||
.with_status(StatusCode::CONFLICT)
|
||||
},
|
||||
ZoneError::NotFound { name } => {
|
||||
Error::new("zone:not_found", "The zone {zone_name} could not be found")
|
||||
.with_details(json!({
|
||||
"zone_name": name
|
||||
}))
|
||||
.with_status(StatusCode::NOT_FOUND)
|
||||
},
|
||||
ZoneError::Validation { suberrors } => {
|
||||
Error::new("zone:validation", "Error while validating zone input data")
|
||||
.with_suberrors(suberrors)
|
||||
.with_status(StatusCode::BAD_REQUEST)
|
||||
},
|
||||
ZoneError::NotExistsNs { name } => {
|
||||
Error::new("zone:not_exists_ns", "The zone {zone_name} does not exist on the name server")
|
||||
.with_details(json!({
|
||||
"zone_name": name
|
||||
}))
|
||||
.with_status(StatusCode::BAD_REQUEST)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DomainValidationError> for Error {
|
||||
fn from(value: DomainValidationError) -> Self {
|
||||
match value {
|
||||
DomainValidationError::CharactersNotPermitted { label } => {
|
||||
Error::new("domain:characters_not_permitted", "Domain name label {label} contains characters not permitted. The allowed characters are lowercase alphanumeric characters (a-z and 0-9), the dash ('-'), the underscore ('_') and the forward slash ('/').")
|
||||
.with_details(json!({
|
||||
"label": label
|
||||
}))
|
||||
},
|
||||
DomainValidationError::EmptyDomain => {
|
||||
Error::new("domain:empty_domain", "Domain name can not be empty or the root domain ('.')")
|
||||
},
|
||||
DomainValidationError::EmptyLabel => {
|
||||
Error::new("domain:empty_label", "Domain name contains empty labels (repeated dots)")
|
||||
},
|
||||
DomainValidationError::DomainTooLong { length } => {
|
||||
Error::new("domain:domain_too_long", "Domain name too long ({length} characters), the maximum length is 255 characters")
|
||||
.with_details(json!({
|
||||
"length": length
|
||||
}))
|
||||
},
|
||||
DomainValidationError::LabelToolLong { length, label } => {
|
||||
Error::new("domain:label_too_long", "Domain name label {label} is too long ({label_length} characters), the maximum length is 63 characters")
|
||||
.with_details(json!({
|
||||
"label": label,
|
||||
"length": length,
|
||||
}))
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TxtParseError> for Error {
|
||||
fn from(value: TxtParseError) -> Self {
|
||||
match value {
|
||||
TxtParseError::BadEscapeDigitIndexTooHigh { sequence } => {
|
||||
Error::new("record:txt:parse:escape_decimal_index_too_high", "Octect escape sequence should be between 000 and 255. Offending escape sequence: \\{sequence}")
|
||||
.with_details(json!({
|
||||
"sequence": sequence
|
||||
}))
|
||||
},
|
||||
TxtParseError::BadEscapeDigitsNotDigits { sequence } => {
|
||||
Error::new("record:txt:parse:escape_decimal_not_digits", "Expected an octect escape sequence due to the presence of a back slash (\\) followed by a digit but found non digit characters. Offending escape sequence: \\{sequence}")
|
||||
.with_details(json!({
|
||||
"sequence": sequence
|
||||
}))
|
||||
},
|
||||
TxtParseError::BadEscapeDigitsTooShort { sequence } => {
|
||||
Error::new("record:txt:parse:escape_decimal_too_short", "Expected an octect escape sequence due to the presence of a back slash (\\) followed by a digit but found found {sequence_lenght} characters instead of three. Offending escape sequence: \\{sequence}")
|
||||
.with_details(json!({
|
||||
"sequence": sequence,
|
||||
"sequence_lenght": sequence.len()
|
||||
}))
|
||||
},
|
||||
TxtParseError::MissingEscape => {
|
||||
Error::new("record:txt:parse:escape_missing", "Expected an escape sequence due to the presence of a back slash (\\) at the end of the input but found nothing")
|
||||
},
|
||||
TxtParseError::NonAscii { character } => {
|
||||
Error::new("record:txt:parse:non_ascii", "Found a non ASCII character ({character}). Only printable ASCII characters are allowed.")
|
||||
.with_details(json!({
|
||||
"character": character
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl From<DnsDriverError> for Error {
|
||||
fn from(value: DnsDriverError) -> Self {
|
||||
|
||||
match value {
|
||||
DnsDriverError::ConnectionError { reason } => {
|
||||
Error::new("dns:connection", "Error while connecting to the name server")
|
||||
.with_cause(&reason.to_string())
|
||||
},
|
||||
DnsDriverError::OperationError { reason } => {
|
||||
Error::new("dns:operation", "DNS operation error")
|
||||
.with_cause(&reason.to_string())
|
||||
},
|
||||
DnsDriverError::ServerError { rcode, name, qtype } => {
|
||||
Error::new("dns:server", "Unexpected response to query")
|
||||
.with_details(json!({
|
||||
"rcode": rcode,
|
||||
"name": name,
|
||||
"qtype": qtype,
|
||||
}))
|
||||
},
|
||||
DnsDriverError::ZoneNotFound { name } => {
|
||||
Error::new("dns:zone_not_found", "The zone {zone_name} does not exist on the name server")
|
||||
.with_details(json!({
|
||||
"zone_name": name
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RDataValidationError> for Error {
|
||||
fn from(value: RDataValidationError) -> Self {
|
||||
match value {
|
||||
RDataValidationError::Ip4Address { input } => {
|
||||
Error::new("record:parse:ip4", "The following IPv4 address {input} is invalid. IPv4 addresses should have four numbers, each between 0 and 255, separated by dots.")
|
||||
.with_details(json!({
|
||||
"input": input
|
||||
}))
|
||||
},
|
||||
RDataValidationError::Ip6Address { input } => {
|
||||
Error::new("record:parse:ip6", "The following IPv4 address {input} is invalid. IPv6 addresses should have eight groups of four hexadecimal digit separated by colons. Leftmost zeros in a group can be omitted, sequence of zeros can be shorted by a double colons.")
|
||||
.with_details(json!({
|
||||
"input": input
|
||||
}))
|
||||
},
|
||||
RDataValidationError::IpAddress { input } => {
|
||||
Error::new("record:parse:ip", "The IP address {input} is invalid. It should be either an IPv4 address, four numbers between 0 and 255 separated by dots, or and IPv6 address, eight groups of four hexadecimal digit separated by colons.")
|
||||
.with_details(json!({
|
||||
"input": input
|
||||
}))
|
||||
},
|
||||
RDataValidationError::Number { min, max } => {
|
||||
Error::new("record:parse:number", "Expected a number between {min} and {max}")
|
||||
.with_details(json!({
|
||||
"min": min,
|
||||
"max": max,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RecordValidationError> for Error {
|
||||
fn from(value: RecordValidationError) -> Self {
|
||||
match value {
|
||||
RecordValidationError::NotInZone { name, zone } => {
|
||||
Error::new("record:parse:not_in_zone", "The domain name {name} is not in the current zone ({zone})")
|
||||
.with_details(json!({
|
||||
"name": name,
|
||||
"zone": zone
|
||||
}))
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<RecordError > for Error {
|
||||
fn from(value: RecordError) -> Self {
|
||||
match value {
|
||||
RecordError::Validation { suberrors } => {
|
||||
Error::new("record:validation", "Error while validating input records")
|
||||
.with_suberrors(suberrors)
|
||||
.with_status(StatusCode::BAD_REQUEST)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<TemplateError> for Error {
|
||||
fn from(value: TemplateError) -> Self {
|
||||
match value {
|
||||
TemplateError::RenderError { name, reason } => {
|
||||
let mut this_reason = reason.as_ref();
|
||||
let mut cause = format!("{}", this_reason);
|
||||
|
||||
while let Some(source) = this_reason.source() {
|
||||
cause.push_str(&format!(": {source}"));
|
||||
this_reason = source;
|
||||
}
|
||||
|
||||
Error::new("template:render", "Failed to render the template")
|
||||
.with_details(json!({
|
||||
"name": name
|
||||
}))
|
||||
.with_cause(&cause)
|
||||
},
|
||||
TemplateError::SerializationError { reason } => {
|
||||
Error::new("template:serialization", "Failed to serialize context")
|
||||
.with_cause(&reason.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ProtoDnsError> for Error {
|
||||
fn from(value: ProtoDnsError) -> Self {
|
||||
match value {
|
||||
ProtoDnsError::RDataUnknown { input, field, rtype } => {
|
||||
Error::new("proto:dns:rdata_unknown", "Unknown error while converting internal rdata to proto rdata")
|
||||
.with_details(json!({
|
||||
"input": input,
|
||||
"field": field,
|
||||
"rtype": rtype,
|
||||
}))
|
||||
},
|
||||
ProtoDnsError::NameParseError { input } => {
|
||||
Error::new("proto:dns:name_unknown", "Unknown error while parsing name")
|
||||
.with_details(json!({
|
||||
"input": input
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<InputDataError> for Error {
|
||||
fn from(value: InputDataError) -> Self {
|
||||
match value {
|
||||
InputDataError::TypeError { expected, found } => {
|
||||
Error::new("input:type_error", "Expected to find type {expected} found type {found}.")
|
||||
.with_details(json!({
|
||||
"expected": expected,
|
||||
"found": found,
|
||||
}))
|
||||
},
|
||||
InputDataError::MissingValue => {
|
||||
Error::new("input:missing_value", "The value at the given path is required.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
250
src/form.rs
Normal file
250
src/form.rs
Normal file
|
@ -0,0 +1,250 @@
|
|||
use std::collections::HashMap;
|
||||
|
||||
use axum::extract::{Request, FromRequest};
|
||||
use axum::response::{Response, IntoResponse};
|
||||
use axum::http::StatusCode;
|
||||
use axum::Form;
|
||||
|
||||
impl<S> FromRequest<S> for Node
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = Response;
|
||||
|
||||
async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let Form(data): Form<Vec<(String, String)>> = Form::from_request(req, state)
|
||||
.await
|
||||
.map_err(IntoResponse::into_response)?;
|
||||
|
||||
let node = Node::from_key_value(data)
|
||||
.map_err(|_| StatusCode::UNPROCESSABLE_ENTITY.into_response())?;
|
||||
|
||||
Ok(node)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum FormError {
|
||||
MismatchedType
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Node {
|
||||
Value(String),
|
||||
Map(HashMap<String, Node>),
|
||||
Sequence(Sequence)
|
||||
}
|
||||
|
||||
impl Node {
|
||||
pub fn from_key_value(data: Vec<(String, String)>) -> Result<Node, FormError> {
|
||||
let mut form = Node::Map(HashMap::new());
|
||||
for (key, value) in data {
|
||||
|
||||
// Consider empty value not filled and remove them
|
||||
if value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = Self::parse_key(&key);
|
||||
let mut parent_node = &mut form;
|
||||
|
||||
for window in path.windows(2) {
|
||||
let parent_key = &window[0];
|
||||
let child_key = &window[1];
|
||||
|
||||
parent_node = match (parent_node, parent_key) {
|
||||
(&mut Node::Map(ref mut map), Key::Attribute(key)) => {
|
||||
map.entry(key.clone()).or_insert_with(|| child_key.new_node())
|
||||
},
|
||||
(&mut Node::Sequence(Sequence::ImplicitIndex(ref mut list)), Key::ImplicitIndex) => {
|
||||
list.push(child_key.new_node());
|
||||
list.last_mut().unwrap()
|
||||
},
|
||||
(&mut Node::Sequence(Sequence::ExplicitIndex(ref mut list)), Key::ExplicitIndex(index)) => {
|
||||
list.entry(*index).or_insert_with(|| child_key.new_node())
|
||||
},
|
||||
_ => {
|
||||
return Err(FormError::MismatchedType);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let last_key = path.last().unwrap();
|
||||
match (parent_node, last_key) {
|
||||
(&mut Node::Map(ref mut map), Key::Attribute(key)) => {
|
||||
map.insert(key.clone(), Node::Value(value));
|
||||
},
|
||||
(&mut Node::Sequence(Sequence::ImplicitIndex(ref mut list)), Key::ImplicitIndex) => {
|
||||
list.push(Node::Value(value))
|
||||
},
|
||||
(&mut Node::Sequence(Sequence::ExplicitIndex(ref mut list)), Key::ExplicitIndex(index)) => {
|
||||
list.insert(*index, Node::Value(value));
|
||||
},
|
||||
_ => {
|
||||
return Err(FormError::MismatchedType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(form)
|
||||
|
||||
}
|
||||
|
||||
pub fn parse_key(key: &str) -> Vec<Key> {
|
||||
let keys = if let Some((head, tail)) = key.split_once('[') {
|
||||
let mut keys = vec![head];
|
||||
keys.extend(tail.trim_end_matches(']').split("]["));
|
||||
keys
|
||||
} else {
|
||||
vec![key]
|
||||
};
|
||||
|
||||
keys.iter().map(|key| {
|
||||
if key.is_empty() {
|
||||
Key::ImplicitIndex
|
||||
} else if let Ok(index) = key.parse::<usize>() {
|
||||
Key::ExplicitIndex(index)
|
||||
} else {
|
||||
Key::Attribute(key.to_string())
|
||||
}
|
||||
}).collect()
|
||||
}
|
||||
|
||||
pub fn into_json_value(self) -> serde_json::Value {
|
||||
match self {
|
||||
Node::Value(value) => serde_json::Value::String(value),
|
||||
Node::Map(map) => {
|
||||
let map = map.into_iter()
|
||||
.map(|(key, node)| (key, node.into_json_value()))
|
||||
.collect();
|
||||
serde_json::Value::Object(map)
|
||||
},
|
||||
Node::Sequence(list) => {
|
||||
let array = list.to_vec()
|
||||
.into_iter()
|
||||
.map(|node| node.into_json_value())
|
||||
.collect();
|
||||
|
||||
serde_json::Value::Array(array)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Sequence {
|
||||
ImplicitIndex(Vec<Node>),
|
||||
ExplicitIndex(HashMap<usize, Node>),
|
||||
}
|
||||
|
||||
impl Sequence {
|
||||
pub fn to_vec(self) -> Vec<Node> {
|
||||
match self {
|
||||
Sequence::ImplicitIndex(list) => list,
|
||||
Sequence::ExplicitIndex(map) => {
|
||||
let mut key_values: Vec<(usize, Node)> = map.into_iter().collect();
|
||||
key_values.sort_by_key(|(k, _)| *k);
|
||||
key_values.into_iter().map(|(_, v)| v).collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Key {
|
||||
ExplicitIndex(usize),
|
||||
ImplicitIndex,
|
||||
Attribute(String),
|
||||
}
|
||||
|
||||
impl Key {
|
||||
pub fn new_node(&self) -> Node {
|
||||
match self {
|
||||
Key::ExplicitIndex(_) => Node::Sequence(Sequence::ExplicitIndex(HashMap::new())),
|
||||
Key::ImplicitIndex => Node::Sequence(Sequence::ImplicitIndex(Vec::new())),
|
||||
Key::Attribute(_) => Node::Map(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
pub fn test_parse_key() {
|
||||
let key = "records[0][addresses][][address]".to_string();
|
||||
let parsed_key = vec![
|
||||
Key::Attribute("records".to_string()),
|
||||
Key::ExplicitIndex(0),
|
||||
Key::Attribute("addresses".to_string()),
|
||||
Key::ImplicitIndex,
|
||||
Key::Attribute("address".to_string()),
|
||||
];
|
||||
assert_eq!(Node::parse_key(&key), parsed_key);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_parse_key_value() {
|
||||
let form_data = vec![
|
||||
("records[0][addresses][][address]".to_string(), "123".to_string()),
|
||||
("records[0][addresses][][address]".to_string(), "abc".to_string()),
|
||||
];
|
||||
let mut address1 = HashMap::new();
|
||||
address1.insert("address".to_string(), Node::Value("123".to_string()));
|
||||
let mut address2 = HashMap::new();
|
||||
address2.insert("address".to_string(), Node::Value("abc".to_string()));
|
||||
|
||||
let addresses = vec![Node::Map(address1), Node::Map(address2)];
|
||||
|
||||
let mut record = HashMap::new();
|
||||
record.insert("addresses".to_string(), Node::Sequence(Sequence::ImplicitIndex(addresses)));
|
||||
|
||||
let mut record_list = HashMap::new();
|
||||
record_list.insert(0, Node::Map(record));
|
||||
|
||||
let mut form = HashMap::new();
|
||||
form.insert("records".to_string(), Node::Sequence(Sequence::ExplicitIndex(record_list)));
|
||||
|
||||
let parsed_form = Node::Map(form);
|
||||
|
||||
assert_eq!(Node::from_key_value(form_data).unwrap(), parsed_form);
|
||||
}
|
||||
|
||||
#[test]
|
||||
pub fn test_json_value() {
|
||||
let mut address1 = HashMap::new();
|
||||
address1.insert("address".to_string(), Node::Value("123".to_string()));
|
||||
let mut address2 = HashMap::new();
|
||||
address2.insert("address".to_string(), Node::Value("abc".to_string()));
|
||||
|
||||
let addresses = vec![Node::Map(address1), Node::Map(address2)];
|
||||
|
||||
let mut record = HashMap::new();
|
||||
record.insert("addresses".to_string(), Node::Sequence(Sequence::ImplicitIndex(addresses)));
|
||||
|
||||
let mut record_list = HashMap::new();
|
||||
record_list.insert(0, Node::Map(record));
|
||||
|
||||
let mut form = HashMap::new();
|
||||
form.insert("records".to_string(), Node::Sequence(Sequence::ExplicitIndex(record_list)));
|
||||
|
||||
let parsed_form = Node::Map(form);
|
||||
|
||||
let json_value = json!({
|
||||
"records": [
|
||||
{
|
||||
"addresses": [
|
||||
{ "address": "123" },
|
||||
{ "address": "abc" },
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
assert_eq!(parsed_form.into_json_value(), json_value);
|
||||
}
|
||||
}
|
243
src/localization.rs
Normal file
243
src/localization.rs
Normal file
|
@ -0,0 +1,243 @@
|
|||
use std::{collections::HashMap, fs, path::Path, str::FromStr};
|
||||
|
||||
use fluent_bundle::{concurrent::FluentBundle, FluentArgs, FluentResource, FluentValue};
|
||||
use unic_langid::LanguageIdentifier;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Localization {
|
||||
bundles: std::sync::Arc<HashMap<LanguageIdentifier, FluentBundle<FluentResource>>>,
|
||||
}
|
||||
|
||||
impl Localization {
|
||||
pub fn init(locale_directory: &Path) -> Self {
|
||||
let mut bundles = HashMap::new();
|
||||
let directory_content =fs::read_dir(locale_directory)
|
||||
.expect("Unable to read locales directory");
|
||||
|
||||
for lang_dir in directory_content {
|
||||
let lang_dir = lang_dir.expect("I/O error");
|
||||
if lang_dir.path().is_dir() {
|
||||
let langid: LanguageIdentifier = lang_dir.file_name()
|
||||
.into_string()
|
||||
.expect("String convertion failed")
|
||||
.parse()
|
||||
.expect("Failed to parse language tag");
|
||||
|
||||
let mut bundle = FluentBundle::new_concurrent(vec![langid.clone()]);
|
||||
|
||||
let directory_content = fs::read_dir(lang_dir.path())
|
||||
.expect("Unable to read locales directory");
|
||||
for resource in directory_content {
|
||||
let resource = resource.expect("I/O error");
|
||||
|
||||
if resource.path().is_file() {
|
||||
let resource = fs::read_to_string(resource.path())
|
||||
.expect("Failed to open file");
|
||||
|
||||
let resource = FluentResource::try_new(resource)
|
||||
.expect("Failed to parse an FTL string.");
|
||||
|
||||
bundle
|
||||
.add_resource(resource)
|
||||
.expect("Failed to add FTL resources to the bundle.");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
bundles.insert(langid, bundle);
|
||||
}
|
||||
}
|
||||
|
||||
Localization {
|
||||
bundles: std::sync::Arc::new(bundles),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn language_middleware(&self, default_locale: LanguageIdentifier) -> ExtractLanguageLayer {
|
||||
ExtractLanguageLayer {
|
||||
default_locale,
|
||||
supported_locales: std::sync::Arc::new(self.bundles.keys().cloned().collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl tera::Function for Localization {
|
||||
fn call(&self, args: &HashMap<String, tera::Value>) -> tera::Result<tera::Value> {
|
||||
let locale = args.get("lang")
|
||||
.and_then(|lang| lang.as_str())
|
||||
.and_then(|lang| LanguageIdentifier::from_str(lang).ok())
|
||||
.ok_or(tera::Error::msg("localize: Missing lang parameter"))?;
|
||||
|
||||
let msg = args.get("msg")
|
||||
.and_then(|lang| lang.as_str())
|
||||
.ok_or(tera::Error::msg("localize: Missing msg parameter"))?;
|
||||
|
||||
let attribute = args.get("attr")
|
||||
.and_then(|lang| lang.as_str());
|
||||
|
||||
let bundle = self.bundles.get(&locale)
|
||||
.ok_or_else(|| tera::Error::msg(format!("localize: Could not find locale {locale}")))?;
|
||||
|
||||
let message = if let Some(message) = bundle.get_message(msg) {
|
||||
message
|
||||
} else {
|
||||
eprintln!("[warn] localize: could not find message '{msg}'");
|
||||
return Ok(tera::Value::from(format!("{{{msg}}}")));
|
||||
};
|
||||
|
||||
|
||||
let pattern = if let Some(attribute) = attribute {
|
||||
let pattern = message.get_attribute(attribute)
|
||||
.map(|attribute| attribute.value());
|
||||
|
||||
if let Some(pattern) = pattern {
|
||||
pattern
|
||||
} else {
|
||||
eprintln!("[warn] localize: could not find message '{msg}.{attribute}'");
|
||||
return Ok(tera::Value::from(format!("{{{msg}.{attribute}}}")));
|
||||
}
|
||||
} else {
|
||||
message.value()
|
||||
.ok_or_else(|| tera::Error::msg(format!("localize: Message {msg} has no value")))?
|
||||
};
|
||||
|
||||
let mut msg_args = FluentArgs::new();
|
||||
|
||||
let extra_args = args.get("extra_args")
|
||||
.and_then(|extra_args| extra_args.as_object());
|
||||
|
||||
if let Some(extra_args) = extra_args {
|
||||
for (key, value) in extra_args {
|
||||
msg_args.set(key, fluent_value_from_tera(value, key)?);
|
||||
}
|
||||
}
|
||||
|
||||
for (key, value) in args {
|
||||
if key != "msg" && key != "lang" && key != "attr" && key != "extra_args" {
|
||||
msg_args.set(key, fluent_value_from_tera(value, key)?);
|
||||
}
|
||||
}
|
||||
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let localized_message = bundle.format_pattern(pattern, Some(&msg_args), &mut errors);
|
||||
|
||||
for err in errors {
|
||||
eprint!("[warn] localization error: {err}");
|
||||
}
|
||||
|
||||
Ok(tera::Value::from(localized_message))
|
||||
|
||||
}
|
||||
|
||||
fn is_safe(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fn fluent_value_from_tera<'s>(value: &'s tera::Value, name: &str) -> tera::Result<FluentValue<'s>> {
|
||||
match value {
|
||||
tera::Value::String(string) => Ok(FluentValue::from(string)),
|
||||
tera::Value::Number(number) => Ok(FluentValue::from(number.as_f64())),
|
||||
_ => Err(tera::Error::msg(format!("localize: Argument {name} can only be a string or a number"))),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ExtractLanguageLayer {
|
||||
default_locale: LanguageIdentifier,
|
||||
supported_locales: std::sync::Arc<Vec<LanguageIdentifier>>,
|
||||
}
|
||||
|
||||
impl<S> tower::Layer<S> for ExtractLanguageLayer {
|
||||
|
||||
type Service = ExtractLanguageService<S>;
|
||||
|
||||
fn layer(&self, inner: S) -> Self::Service {
|
||||
ExtractLanguageService {
|
||||
inner,
|
||||
default_locale: self.default_locale.clone(),
|
||||
supported_locales: self.supported_locales.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ExtractLanguageService<S> {
|
||||
inner: S,
|
||||
default_locale: LanguageIdentifier,
|
||||
supported_locales: std::sync::Arc<Vec<LanguageIdentifier>>,
|
||||
}
|
||||
|
||||
impl<S> ExtractLanguageService<S> {
|
||||
// https://httpwg.org/specs/rfc9110.html#field.accept-language
|
||||
// TODO: Test language selection for compound locales (eg. fr-FR)
|
||||
pub fn language_from_header(&self, headers: &axum::http::HeaderMap) -> LanguageIdentifier {
|
||||
let lang_preferences = headers
|
||||
.get("Accept-Language")
|
||||
.and_then(|val| val.to_str().ok());
|
||||
|
||||
if let Some(lang_preferences) = lang_preferences {
|
||||
let mut languages = Vec::new();
|
||||
|
||||
for lang_item in lang_preferences.split(",") {
|
||||
let lang_item = lang_item.trim();
|
||||
let lang_config = lang_item.split_once(';');
|
||||
if let Some((lang_id, config)) = lang_config {
|
||||
let lang_id = lang_id.trim();
|
||||
let preference = config.trim().strip_prefix("q=")
|
||||
.and_then(|value| value.parse::<f32>().ok());
|
||||
if let Some(preference) = preference {
|
||||
languages.push((lang_id.trim(), preference));
|
||||
}
|
||||
} else {
|
||||
languages.push((lang_item, 1f32));
|
||||
}
|
||||
}
|
||||
|
||||
languages.sort_by(|l1, l2| l1.1.total_cmp(&l2.1));
|
||||
|
||||
let iter = languages.into_iter()
|
||||
.rev()
|
||||
.filter(|l| l.1 >= 0.001 && l.1 <= 1f32)
|
||||
.filter_map(|(lang, _)| LanguageIdentifier::from_str(lang).ok());
|
||||
|
||||
for lang in iter {
|
||||
for supported_lang in self.supported_locales.iter() {
|
||||
if lang == *supported_lang {
|
||||
return lang;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.default_locale.clone()
|
||||
} else {
|
||||
self.default_locale.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> tower::Service<axum::extract::Request> for ExtractLanguageService<S>
|
||||
where
|
||||
S: tower::Service<axum::extract::Request, Response = axum::response::Response> + Send + 'static,
|
||||
S::Future: Send + 'static,
|
||||
{
|
||||
type Response = S::Response;
|
||||
type Error = S::Error;
|
||||
// `BoxFuture` is a type alias for `Pin<Box<dyn Future + Send + 'a>>`
|
||||
type Future = std::pin::Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
|
||||
|
||||
fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> std::task::Poll<Result<(), Self::Error>> {
|
||||
self.inner.poll_ready(cx)
|
||||
}
|
||||
|
||||
fn call(&mut self, mut req: axum::extract::Request) -> Self::Future {
|
||||
let headers = req.headers();
|
||||
|
||||
let lang = self.language_from_header(headers);
|
||||
|
||||
req.extensions_mut().insert(lang);
|
||||
|
||||
Box::pin(self.inner.call(req))
|
||||
}
|
||||
}
|
94
src/macros.rs
Normal file
94
src/macros.rs
Normal file
|
@ -0,0 +1,94 @@
|
|||
macro_rules! push_error {
|
||||
($value:expr, $errors:expr) => {
|
||||
match $value {
|
||||
Err(error) => { $errors.push(error); None },
|
||||
Ok(value) => Some(value)
|
||||
}
|
||||
};
|
||||
($value:expr, $errors:expr, $path:expr) => {
|
||||
match $value {
|
||||
Err(error) => { $errors.push(error.with_path($path)); None },
|
||||
Ok(value) => Some(value)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! append_errors {
|
||||
($value:expr, $errors:expr) => {
|
||||
match $value {
|
||||
Err(mut err) => { $errors.append(&mut err); None },
|
||||
Ok(value) => Some(value)
|
||||
}
|
||||
};
|
||||
($value:expr, $errors:expr, $path:expr) => {
|
||||
match $value {
|
||||
Err(err) => { $errors.extend(err.into_iter().map(|e| {
|
||||
e.with_path($path)
|
||||
})); None },
|
||||
Ok(value) => Some(value)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! check_type {
|
||||
($value:expr, Number, $errors:expr) => {
|
||||
if let serde_json::Value::Number(value) = $value {
|
||||
Some(value)
|
||||
} else {
|
||||
|
||||
let value = if let serde_json::Value::String(ref value) = $value {
|
||||
value.parse::<i64>().ok()
|
||||
.map(serde_json::Number::from)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if value.is_some() {
|
||||
value
|
||||
} else {
|
||||
use crate::resources::dns::friendly::InputDataError;
|
||||
use crate::resources::dns::friendly::ValueType;
|
||||
use crate::errors::Error;
|
||||
|
||||
$errors.push(Error::from(InputDataError::TypeError { expected: ValueType::Number, found: ValueType::from_value(&$value) }));
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
($value:expr, $type:ident, $errors:expr) => {
|
||||
if let serde_json::Value::$type(value) = $value {
|
||||
Some(value)
|
||||
} else {
|
||||
use crate::resources::dns::friendly::InputDataError;
|
||||
use crate::resources::dns::friendly::ValueType;
|
||||
use crate::errors::Error;
|
||||
|
||||
$errors.push(Error::from(InputDataError::TypeError { expected: ValueType::$type, found: ValueType::from_value(&$value) }));
|
||||
None
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! get_object_value {
|
||||
($value:expr, $key:expr, $errors:expr) => {
|
||||
if let Some(value) = $value.remove($key) {
|
||||
Some(value)
|
||||
} else {
|
||||
use crate::resources::dns::friendly::InputDataError;
|
||||
use crate::errors::Error;
|
||||
|
||||
$errors.push(
|
||||
Error::from(InputDataError::MissingValue)
|
||||
.with_path(concat!("/", $key))
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
pub(crate) use append_errors;
|
||||
pub(crate) use push_error;
|
||||
pub(crate) use check_type;
|
||||
pub(crate) use get_object_value;
|
95
src/main.rs
95
src/main.rs
|
@ -1,26 +1,84 @@
|
|||
#![feature(proc_macro_hygiene, decl_macro)]
|
||||
|
||||
|
||||
#[macro_use] extern crate rocket;
|
||||
#[macro_use] extern crate diesel;
|
||||
#[macro_use] extern crate diesel_migrations;
|
||||
|
||||
mod routes;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod errors;
|
||||
mod dns;
|
||||
mod models;
|
||||
mod schema;
|
||||
mod routes;
|
||||
mod resources;
|
||||
mod database;
|
||||
mod validation;
|
||||
mod macros;
|
||||
mod template;
|
||||
mod controllers;
|
||||
mod proto;
|
||||
mod localization;
|
||||
mod form;
|
||||
|
||||
use std::process::exit;
|
||||
use std::sync::Arc;
|
||||
|
||||
use clap::Parser;
|
||||
use figment::{Figment, Profile, providers::{Format, Toml, Env}};
|
||||
use rocket_sync_db_pools::database;
|
||||
use diesel::prelude::*;
|
||||
use axum::Router;
|
||||
use axum::routing;
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
use database::sqlite::SqliteDB;
|
||||
use database::BoxedDb;
|
||||
use dns::dns_driver::DnsDriverConfig;
|
||||
use dns::dns_driver::TsigConfig;
|
||||
use dns::{ZoneDriver, RecordDriver};
|
||||
use template::TemplateEngine;
|
||||
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
zone: Arc<dyn ZoneDriver>,
|
||||
records: Arc<dyn RecordDriver>,
|
||||
db: BoxedDb,
|
||||
template_engine: TemplateEngine
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let localization = localization::Localization::init(std::path::Path::new("./locales"));
|
||||
|
||||
let template_engine = TemplateEngine::new(
|
||||
std::path::Path::new("./templates"),
|
||||
localization.clone()
|
||||
);
|
||||
|
||||
let dns_driver = dns::dns_driver::DnsDriver::from_config(DnsDriverConfig {
|
||||
address: "127.0.0.1:5353".parse().unwrap(),
|
||||
tsig: Some(TsigConfig {
|
||||
key_name: "dev".parse().unwrap(),
|
||||
secret: domain::utils::base64::decode::<Vec<u8>>("mbmz4J3Efm1BUjqe12M1RHsOnPjYhKQe+2iKO4tL+a4=").unwrap(),
|
||||
algorithm: domain::tsig::Algorithm::Sha256,
|
||||
})
|
||||
});
|
||||
|
||||
let dns_driver = Arc::new(dns_driver);
|
||||
|
||||
let app_state = AppState {
|
||||
zone: dns_driver.clone(),
|
||||
records: dns_driver.clone(),
|
||||
db: Arc::new(SqliteDB::new("db.sqlite".into()).await),
|
||||
template_engine
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
/* ----- API ----- */
|
||||
.route("/api/admin/zones", routing::post(routes::api::zones::create_zone))
|
||||
.route("/api/zones/{zone_name}/records", routing::get(routes::api::zones::get_zone_records))
|
||||
.route("/api/zones/{zone_name}/records", routing::post(routes::api::zones::create_zone_records))
|
||||
/* ----- UI ----- */
|
||||
.route("/zones/{zone_name}/records", routing::get(routes::ui::zones::get_records_page))
|
||||
.route("/zones/{zone_name}/records/new", routing::get(routes::ui::zones::get_new_record_page))
|
||||
.route("/zones/{zone_name}/records/new", routing::post(routes::ui::zones::post_new_record))
|
||||
.nest_service("/assets", ServeDir::new("assets"))
|
||||
.with_state(app_state)
|
||||
.layer(localization.language_middleware("en".parse().unwrap()));
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:8000").await.unwrap();
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*
|
||||
use crate::cli::{NomiloCli, NomiloCommand};
|
||||
|
||||
#[database("sqlite")]
|
||||
|
@ -63,3 +121,4 @@ fn main() {
|
|||
let nomilo = NomiloCli::parse();
|
||||
nomilo.run(figment, app_config);
|
||||
}
|
||||
*/
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dns;
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub enum DNSClass {
|
||||
IN,
|
||||
CH,
|
||||
HS,
|
||||
NONE,
|
||||
ANY,
|
||||
OPT(u16),
|
||||
}
|
||||
|
||||
impl From<dns::DNSClass> for DNSClass {
|
||||
fn from(dns_class: dns::DNSClass) -> DNSClass {
|
||||
match dns_class {
|
||||
dns::DNSClass::IN => DNSClass::IN,
|
||||
dns::DNSClass::CH => DNSClass::CH,
|
||||
dns::DNSClass::HS => DNSClass::HS,
|
||||
dns::DNSClass::NONE => DNSClass::NONE,
|
||||
dns::DNSClass::ANY => DNSClass::ANY,
|
||||
dns::DNSClass::OPT(v) => DNSClass::OPT(v),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DNSClass> for dns::DNSClass {
|
||||
fn from(dns_class: DNSClass) -> dns::DNSClass {
|
||||
match dns_class {
|
||||
DNSClass::IN => dns::DNSClass::IN,
|
||||
DNSClass::CH => dns::DNSClass::CH,
|
||||
DNSClass::HS => dns::DNSClass::HS,
|
||||
DNSClass::NONE => dns::DNSClass::NONE,
|
||||
DNSClass::ANY => dns::DNSClass::ANY,
|
||||
DNSClass::OPT(v) => dns::DNSClass::OPT(v),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,176 +0,0 @@
|
|||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use rocket::http::Status;
|
||||
use rocket::request::{Request, Outcome};
|
||||
use rocket::response::{self, Response, Responder};
|
||||
use rocket::serde::json::Json;
|
||||
use serde_json::Value;
|
||||
use diesel::result::Error as DieselError;
|
||||
use argon2::password_hash::errors::Error as PasswordHashError;
|
||||
|
||||
use crate::dns::ConnectorError;
|
||||
use crate::models;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum UserError {
|
||||
ZoneNotFound,
|
||||
NotFound,
|
||||
UserConflict,
|
||||
BadCreds,
|
||||
MissingToken,
|
||||
ExpiredSession,
|
||||
MalformedHeader,
|
||||
PermissionDenied,
|
||||
DbError(DieselError),
|
||||
PasswordError(PasswordHashError),
|
||||
}
|
||||
|
||||
impl From<DieselError> for UserError {
|
||||
fn from(e: DieselError) -> Self {
|
||||
UserError::DbError(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PasswordHashError> for UserError {
|
||||
fn from(e: PasswordHashError) -> Self {
|
||||
UserError::PasswordError(e)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
pub struct ErrorResponse {
|
||||
#[serde(with = "StatusDef")]
|
||||
#[serde(flatten)]
|
||||
pub status: Status,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub details: Option<Value>
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(remote = "Status")]
|
||||
struct StatusDef {
|
||||
code: u16,
|
||||
#[serde(rename = "status", getter = "Status::reason")]
|
||||
reason: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl ErrorResponse {
|
||||
pub fn new(status: Status, message: String) -> ErrorResponse {
|
||||
ErrorResponse {
|
||||
status,
|
||||
message,
|
||||
details: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_details<T: Serialize> (self, details: T) -> ErrorResponse {
|
||||
ErrorResponse {
|
||||
details: serde_json::to_value(details).ok(),
|
||||
..self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn err<R>(self) -> Result<R, ErrorResponse> {
|
||||
Err(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'r> Responder<'r, 'static> for ErrorResponse {
|
||||
fn respond_to(self, req: &'r Request<'_>) -> response::Result<'static> {
|
||||
let status = self.status;
|
||||
Response::build_from(Json(self).respond_to(req)?).status(status).ok()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserError> for ErrorResponse {
|
||||
fn from(e: UserError) -> Self {
|
||||
match e {
|
||||
UserError::BadCreds => ErrorResponse::new(Status::Unauthorized, "Provided credentials or token do not match any existing user".into()),
|
||||
UserError::UserConflict => ErrorResponse::new(Status::Conflict, "This user already exists".into()),
|
||||
UserError::NotFound => ErrorResponse::new(Status::NotFound, "User does not exist".into()),
|
||||
UserError::MissingToken => ErrorResponse::new(Status::Unauthorized, "Missing authorization token".into()),
|
||||
UserError::ExpiredSession => ErrorResponse::new(Status::Unauthorized, "The provided session token has expired".into()),
|
||||
UserError::MalformedHeader => ErrorResponse::new(Status::BadRequest, "Malformed authorization header".into()),
|
||||
UserError::PermissionDenied => ErrorResponse::new(Status::Forbidden, "Bearer is not authorized to access the resource".into()),
|
||||
UserError::ZoneNotFound => ErrorResponse::new(Status::NotFound, "DNS zone does not exist".into()),
|
||||
UserError::DbError(e) => make_500(e),
|
||||
UserError::PasswordError(e) => make_500(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Box<dyn ConnectorError>> for ErrorResponse {
|
||||
fn from(e: Box<dyn ConnectorError>) -> Self {
|
||||
if e.is_proto_error() {
|
||||
error!("{}", e);
|
||||
return make_500(e);
|
||||
} else {
|
||||
warn!("{}", e);
|
||||
let error = ErrorResponse::new(
|
||||
Status::NotFound,
|
||||
"Zone could not be found".into()
|
||||
);
|
||||
if let Some(zone) = e.zone_name() {
|
||||
return error.with_details(json!({
|
||||
"zone_name": zone.to_utf8()
|
||||
}));
|
||||
} else {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<models::RecordListParseError> for ErrorResponse {
|
||||
fn from(e: models::RecordListParseError) -> Self {
|
||||
match e {
|
||||
models::RecordListParseError::RecordNotInZone { zone, class, mismatched_class, mismatched_zone} => {
|
||||
ErrorResponse::new(
|
||||
Status::BadRequest,
|
||||
"Record list contains records that do not belong to the zone".into()
|
||||
).with_details(
|
||||
json!({
|
||||
"zone_name": zone.to_utf8(),
|
||||
"class": models::DNSClass::from(class),
|
||||
"mismatched_class": mismatched_class,
|
||||
"mismatched_zone": mismatched_zone,
|
||||
})
|
||||
)
|
||||
},
|
||||
models::RecordListParseError::ParseError { zone, bad_records } => {
|
||||
ErrorResponse::new(
|
||||
Status::BadRequest,
|
||||
"Record list contains records that could not be parsed into DNS records".into()
|
||||
).with_details(
|
||||
json!({
|
||||
"zone_name": zone.to_utf8(),
|
||||
"records": bad_records
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl<S> From<ErrorResponse> for Outcome<S, ErrorResponse> {
|
||||
fn from(e: ErrorResponse) -> Self {
|
||||
Outcome::Failure(e.into())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl From<ErrorResponse> for (Status, ErrorResponse) {
|
||||
fn from(e: ErrorResponse) -> Self {
|
||||
(e.status, e)
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: change for Display trait
|
||||
pub fn make_500<E: std::fmt::Debug>(e: E) -> ErrorResponse {
|
||||
error!("Making 500 for Error: {:?}", e);
|
||||
|
||||
ErrorResponse::new(Status::InternalServerError, "An unexpected error occured".into())
|
||||
}
|
|
@ -1,94 +0,0 @@
|
|||
use std::ops::Deref;
|
||||
|
||||
|
||||
use rocket::request::FromParam;
|
||||
use rocket::form::{self, FromFormField, ValueField};
|
||||
use serde::{Deserialize, Serialize, Deserializer, Serializer};
|
||||
use trust_dns_proto::error::ProtoError;
|
||||
|
||||
use crate::dns::Name;
|
||||
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SerdeName(pub(crate)Name);
|
||||
|
||||
impl Deref for SerdeName {
|
||||
type Target = Name;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for SerdeName {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>
|
||||
{
|
||||
use serde::de::Error;
|
||||
|
||||
String::deserialize(deserializer)
|
||||
.and_then(|string|
|
||||
Name::from_utf8(&string)
|
||||
.map_err(|e| Error::custom(e.to_string()))
|
||||
).map( SerdeName)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for SerdeName {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer
|
||||
{
|
||||
self.0.to_utf8().serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl SerdeName {
|
||||
pub fn into_inner(self) -> Name {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_absolute_name(name: &str) -> Result<AbsoluteName, ProtoError> {
|
||||
let mut name = Name::from_utf8(name)?;
|
||||
if !name.is_fqdn() {
|
||||
name.set_fqdn(true);
|
||||
}
|
||||
Ok(AbsoluteName(SerdeName(name)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AbsoluteName(SerdeName);
|
||||
|
||||
impl<'r> FromParam<'r> for AbsoluteName {
|
||||
type Error = ProtoError;
|
||||
|
||||
fn from_param(param: &'r str) -> Result<Self, Self::Error> {
|
||||
let name = parse_absolute_name(param)?;
|
||||
Ok(name)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<'v> FromFormField<'v> for AbsoluteName {
|
||||
fn from_value(field: ValueField<'v>) -> form::Result<'v, Self> {
|
||||
let name = parse_absolute_name(field.value)
|
||||
.map_err(|_| form::Error::validation("Invalid name"))?;
|
||||
|
||||
Ok(name)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl Deref for AbsoluteName {
|
||||
type Target = Name;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AbsoluteName {
|
||||
pub fn into_inner(self) -> Name {
|
||||
self.0.0
|
||||
}
|
||||
}
|
|
@ -1,258 +0,0 @@
|
|||
use std::convert::TryFrom;
|
||||
use std::net::{Ipv6Addr, Ipv4Addr};
|
||||
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use trust_dns_client::serialize::binary::BinEncoder;
|
||||
use trust_dns_proto::error::ProtoError;
|
||||
|
||||
use crate::dns;
|
||||
use super::name::SerdeName;
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[serde(tag = "Type")]
|
||||
#[serde(rename_all = "UPPERCASE")]
|
||||
pub enum RData {
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
A {
|
||||
address: Ipv4Addr
|
||||
},
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
AAAA {
|
||||
address: Ipv6Addr
|
||||
},
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
CAA {
|
||||
issuer_critical: bool,
|
||||
value: String,
|
||||
property_tag: String,
|
||||
},
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
CNAME {
|
||||
target: SerdeName
|
||||
},
|
||||
// HINFO(HINFO),
|
||||
// HTTPS(SVCB),
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
MX {
|
||||
preference: u16,
|
||||
mail_exchanger: SerdeName
|
||||
},
|
||||
// NAPTR(NAPTR),
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
NULL {
|
||||
data: String
|
||||
},
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
NS {
|
||||
target: SerdeName
|
||||
},
|
||||
// OPENPGPKEY(OPENPGPKEY),
|
||||
// OPT(OPT),
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
PTR {
|
||||
target: SerdeName
|
||||
},
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
SOA {
|
||||
master_server_name: SerdeName,
|
||||
maintainer_name: SerdeName,
|
||||
refresh: i32,
|
||||
retry: i32,
|
||||
expire: i32,
|
||||
minimum: u32,
|
||||
serial: u32
|
||||
},
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
SRV {
|
||||
server: SerdeName,
|
||||
port: u16,
|
||||
priority: u16,
|
||||
weight: u16,
|
||||
},
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
SSHFP {
|
||||
algorithm: u8,
|
||||
digest_type: u8,
|
||||
fingerprint: String,
|
||||
},
|
||||
// SVCB(SVCB),
|
||||
// TLSA(TLSA),
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
TXT {
|
||||
text: String
|
||||
},
|
||||
|
||||
// TODO: Eventually allow deserialization of DNSSEC records
|
||||
#[serde(skip)]
|
||||
DNSSEC(dns::DNSSECRData),
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
Unknown {
|
||||
code: u16,
|
||||
data: String,
|
||||
},
|
||||
// ZERO,
|
||||
|
||||
// TODO: DS (added in https://github.com/bluejekyll/trust-dns/pull/1635)
|
||||
// TODO: TLSA
|
||||
}
|
||||
|
||||
impl From<dns::RData> for RData {
|
||||
fn from(rdata: dns::RData) -> RData {
|
||||
match rdata {
|
||||
dns::RData::A(address) => RData::A { address },
|
||||
dns::RData::AAAA(address) => RData::AAAA { address },
|
||||
// Still a draft, no iana number yet, I don't to put something that is not currently supported so that's why NULL and not unknown.
|
||||
// TODO: probably need better error here, I don't know what to do about that as this would require to change the From for something else.
|
||||
// (empty data because I'm lazy)
|
||||
dns::RData::ANAME(_) => RData::NULL {
|
||||
data: String::new()
|
||||
},
|
||||
dns::RData::CNAME(target) => RData::CNAME {
|
||||
target: SerdeName(target)
|
||||
},
|
||||
dns::RData::CAA(caa) => {
|
||||
let value_str = caa.value().to_string();
|
||||
|
||||
RData::CAA {
|
||||
issuer_critical: caa.issuer_critical(),
|
||||
// Remove first and last char (byte) because string is quoted (") (should be a safe operation)
|
||||
value: value_str[1..(value_str.len())].into(),
|
||||
property_tag: caa.tag().as_str().to_string(),
|
||||
}
|
||||
},
|
||||
dns::RData::MX(mx) => RData::MX {
|
||||
preference: mx.preference(),
|
||||
mail_exchanger: SerdeName(mx.exchange().clone())
|
||||
},
|
||||
dns::RData::NULL(null) => RData::NULL {
|
||||
data: general_purpose::STANDARD.encode(null.anything())
|
||||
},
|
||||
dns::RData::NS(target) => RData::NS {
|
||||
target: SerdeName(target)
|
||||
},
|
||||
dns::RData::PTR(target) => RData::PTR {
|
||||
target: SerdeName(target)
|
||||
},
|
||||
dns::RData::SOA(soa) => RData::SOA {
|
||||
master_server_name: SerdeName(soa.mname().clone()),
|
||||
maintainer_name: SerdeName(soa.rname().clone()),
|
||||
refresh: soa.refresh(),
|
||||
retry: soa.retry(),
|
||||
expire: soa.expire(),
|
||||
minimum: soa.minimum(),
|
||||
serial: soa.serial()
|
||||
},
|
||||
dns::RData::SRV(srv) => RData::SRV {
|
||||
server: SerdeName(srv.target().clone()),
|
||||
port: srv.port(),
|
||||
priority: srv.priority(),
|
||||
weight: srv.weight(),
|
||||
},
|
||||
dns::RData::SSHFP(sshfp) => RData::SSHFP {
|
||||
algorithm: sshfp.algorithm().into(),
|
||||
digest_type: sshfp.fingerprint_type().into(),
|
||||
fingerprint: dns::sshfp::HEX.encode(sshfp.fingerprint()),
|
||||
},
|
||||
//TODO: This might alter data if not utf8 compatible, probably need to be replaced
|
||||
//TODO: check whether concatenating txt data is harmful or not
|
||||
dns::RData::TXT(txt) => RData::TXT { text: format!("{}", txt) },
|
||||
dns::RData::DNSSEC(data) => RData::DNSSEC(data),
|
||||
rdata => {
|
||||
let code = rdata.to_record_type().into();
|
||||
let mut data = Vec::new();
|
||||
let mut encoder = BinEncoder::new(&mut data);
|
||||
// TODO: need better error handling (use TryFrom ?)
|
||||
rdata.emit(&mut encoder).expect("could not encode data");
|
||||
|
||||
RData::Unknown {
|
||||
code,
|
||||
data: general_purpose::STANDARD.encode(data),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<RData> for dns::RData {
|
||||
type Error = ProtoError;
|
||||
|
||||
fn try_from(rdata: RData) -> Result<Self, Self::Error> {
|
||||
Ok(match rdata {
|
||||
RData::A { address } => dns::RData::A(address),
|
||||
RData::AAAA { address } => dns::RData::AAAA(address),
|
||||
// TODO: Round trip test all types below (currently not tested...)
|
||||
RData::CAA { issuer_critical, value, property_tag } => {
|
||||
let property = dns::caa::Property::from(property_tag);
|
||||
let caa_value = {
|
||||
// TODO: duplicate of trust_dns_client::serialize::txt::rdata_parser::caa::parse
|
||||
// because caa::read_value is private
|
||||
match property {
|
||||
dns::caa::Property::Issue | dns::caa::Property::IssueWild => {
|
||||
let value = dns::caa::read_issuer(value.as_bytes())?;
|
||||
dns::caa::Value::Issuer(value.0, value.1)
|
||||
}
|
||||
dns::caa::Property::Iodef => {
|
||||
let url = dns::caa::read_iodef(value.as_bytes())?;
|
||||
dns::caa::Value::Url(url)
|
||||
}
|
||||
dns::caa::Property::Unknown(_) => dns::caa::Value::Unknown(value.as_bytes().to_vec()),
|
||||
}
|
||||
};
|
||||
dns::RData::CAA(dns::caa::CAA {
|
||||
issuer_critical,
|
||||
tag: property,
|
||||
value: caa_value,
|
||||
})
|
||||
},
|
||||
RData::CNAME { target } => dns::RData::CNAME(target.into_inner()),
|
||||
RData::MX { preference, mail_exchanger } => dns::RData::MX(
|
||||
dns::mx::MX::new(preference, mail_exchanger.into_inner())
|
||||
),
|
||||
RData::NULL { data } => dns::RData::NULL(
|
||||
dns::null::NULL::with(
|
||||
general_purpose::STANDARD.decode(data).map_err(|e| ProtoError::from(format!("{}", e)))?
|
||||
)
|
||||
),
|
||||
RData::NS { target } => dns::RData::NS(target.into_inner()),
|
||||
RData::PTR { target } => dns::RData::PTR(target.into_inner()),
|
||||
RData::SOA {
|
||||
master_server_name,
|
||||
maintainer_name,
|
||||
refresh,
|
||||
retry,
|
||||
expire,
|
||||
minimum,
|
||||
serial
|
||||
} => dns::RData::SOA(
|
||||
dns::soa::SOA::new(
|
||||
master_server_name.into_inner(),
|
||||
maintainer_name.into_inner(),
|
||||
serial,
|
||||
refresh,
|
||||
retry,
|
||||
expire,
|
||||
minimum,
|
||||
)
|
||||
),
|
||||
RData::SRV { server, port, priority, weight } => dns::RData::SRV(
|
||||
dns::srv::SRV::new(priority, weight, port, server.into_inner())
|
||||
),
|
||||
RData::SSHFP { algorithm, digest_type, fingerprint } => dns::RData::SSHFP(
|
||||
dns::sshfp::SSHFP::new(
|
||||
// NOTE: This allows unassigned algorithms
|
||||
dns::sshfp::Algorithm::from(algorithm),
|
||||
dns::sshfp::FingerprintType::from(digest_type),
|
||||
dns::sshfp::HEX.decode(fingerprint.as_bytes()).map_err(|e| ProtoError::from(format!("{}", e)))?
|
||||
)
|
||||
),
|
||||
RData::TXT { text } => dns::RData::TXT(dns::txt::TXT::new(vec![text])),
|
||||
// TODO: Error out for DNSSEC? Prefer downstream checks?
|
||||
RData::DNSSEC(_) => todo!(),
|
||||
// TODO: Disallow unknown? (could be used to bypass unsopported types?) Prefer downstream checks?
|
||||
RData::Unknown { code: _code, data: _data } => todo!(),
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,122 +0,0 @@
|
|||
use std::convert::{TryFrom, TryInto};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use trust_dns_proto::error::ProtoError;
|
||||
|
||||
use crate::dns;
|
||||
use super::name::SerdeName;
|
||||
use super::class::DNSClass;
|
||||
use super::rdata::RData;
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct Record {
|
||||
#[serde(rename = "Name")]
|
||||
pub name: SerdeName,
|
||||
// TODO: Make class optional, default to IN
|
||||
#[serde(rename = "Class")]
|
||||
pub dns_class: DNSClass,
|
||||
#[serde(rename = "TTL")]
|
||||
pub ttl: u32,
|
||||
#[serde(flatten)]
|
||||
pub rdata: RData,
|
||||
}
|
||||
|
||||
impl From<dns::Record> for Record {
|
||||
fn from(record: dns::Record) -> Record {
|
||||
Record {
|
||||
name: SerdeName(record.name().clone()),
|
||||
dns_class: record.dns_class().into(),
|
||||
ttl: record.ttl(),
|
||||
// Assume data exists, record with empty data should be filtered by caller
|
||||
rdata: record.into_data().unwrap().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Record> for dns::Record {
|
||||
type Error = ProtoError;
|
||||
|
||||
fn try_from(record: Record) -> Result<Self, Self::Error> {
|
||||
let mut trust_dns_record = dns::Record::from_rdata(record.name.into_inner(), record.ttl, record.rdata.try_into()?);
|
||||
trust_dns_record.set_dns_class(record.dns_class.into());
|
||||
Ok(trust_dns_record)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
pub type RecordList = Vec<Record>;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateRecordsRequest {
|
||||
pub old_records: RecordList,
|
||||
pub new_records: RecordList,
|
||||
}
|
||||
|
||||
pub enum RecordListParseError {
|
||||
ParseError {
|
||||
bad_records: Vec<Record>,
|
||||
zone: dns::Name,
|
||||
},
|
||||
RecordNotInZone {
|
||||
zone: dns::Name,
|
||||
class: dns::DNSClass,
|
||||
mismatched_class: Vec<Record>,
|
||||
mismatched_zone: Vec<Record>,
|
||||
},
|
||||
}
|
||||
|
||||
pub trait ParseRecordList {
|
||||
fn try_into_dns_type(self, zone: dns::Name, class: dns::DNSClass) -> Result<Vec<dns::Record>, RecordListParseError>;
|
||||
}
|
||||
|
||||
impl ParseRecordList for RecordList {
|
||||
fn try_into_dns_type(self, zone: dns::Name, class: dns::DNSClass) -> Result<Vec<dns::Record>, RecordListParseError> {
|
||||
// TODO: What about relative names (also in cnames and stuff)
|
||||
let mut bad_records = Vec::new();
|
||||
let mut records: Vec<dns::Record> = Vec::new();
|
||||
let mut mismatched_class: Vec<Record> = Vec::new();
|
||||
let mut mismatched_zone: Vec<Record> = Vec::new();
|
||||
|
||||
for record in self.into_iter() {
|
||||
let this_record = record.clone();
|
||||
if let Ok(record) = dns::Record::try_from(record) {
|
||||
let mut good_record = true;
|
||||
|
||||
if !zone.zone_of(record.name()) {
|
||||
mismatched_zone.push(this_record.clone());
|
||||
good_record = false;
|
||||
}
|
||||
|
||||
if record.dns_class() != class {
|
||||
mismatched_class.push(this_record.clone());
|
||||
good_record = false;
|
||||
}
|
||||
|
||||
if good_record {
|
||||
records.push(record);
|
||||
}
|
||||
} else {
|
||||
bad_records.push(this_record.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if !bad_records.is_empty() {
|
||||
return Err(RecordListParseError::ParseError {
|
||||
zone,
|
||||
bad_records,
|
||||
});
|
||||
}
|
||||
|
||||
if !mismatched_class.is_empty() || !mismatched_zone.is_empty() {
|
||||
return Err(RecordListParseError::RecordNotInZone {
|
||||
zone,
|
||||
class,
|
||||
mismatched_zone,
|
||||
mismatched_class
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(records)
|
||||
}
|
||||
}
|
|
@ -1,93 +0,0 @@
|
|||
use crate::models::user::UserInfo;
|
||||
|
||||
use uuid::Uuid;
|
||||
use diesel::prelude::*;
|
||||
use diesel::result::Error as DieselError;
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
use crate::schema::*;
|
||||
use super::name::AbsoluteName;
|
||||
use super::user::UserZone;
|
||||
use super::errors::UserError;
|
||||
|
||||
|
||||
#[derive(Debug, Serialize, Queryable, Identifiable, Insertable)]
|
||||
#[table_name = "zone"]
|
||||
pub struct Zone {
|
||||
#[serde(skip)]
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddZoneMemberRequest {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, FromForm)]
|
||||
pub struct CreateZoneRequest {
|
||||
pub name: AbsoluteName,
|
||||
}
|
||||
|
||||
// NOTE: Should probably not be implemented here
|
||||
// also, "UserError" seems like a misleading name
|
||||
impl Zone {
|
||||
pub fn get_all(conn: &diesel::SqliteConnection) -> Result<Vec<Zone>, UserError> {
|
||||
use crate::schema::zone::dsl::*;
|
||||
|
||||
zone.get_results(conn)
|
||||
.map_err(UserError::DbError)
|
||||
}
|
||||
|
||||
pub fn get_by_name(conn: &diesel::SqliteConnection, zone_name: &str) -> Result<Zone, UserError> {
|
||||
use crate::schema::zone::dsl::*;
|
||||
|
||||
zone.filter(name.eq(zone_name))
|
||||
.get_result(conn)
|
||||
.map_err(|e| match e {
|
||||
DieselError::NotFound => UserError::ZoneNotFound,
|
||||
other => UserError::DbError(other)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_zone(conn: &diesel::SqliteConnection, zone_request: CreateZoneRequest) -> Result<Zone, UserError> {
|
||||
use crate::schema::zone::dsl::*;
|
||||
|
||||
let new_zone = Zone {
|
||||
id: Uuid::new_v4().to_simple().to_string(),
|
||||
name: zone_request.name.to_utf8(),
|
||||
};
|
||||
|
||||
diesel::insert_into(zone)
|
||||
.values(&new_zone)
|
||||
.execute(conn)
|
||||
.map_err(|e| match e {
|
||||
DieselError::DatabaseError(diesel::result::DatabaseErrorKind::UniqueViolation, _) => UserError::UserConflict,
|
||||
other => UserError::DbError(other)
|
||||
})?;
|
||||
Ok(new_zone)
|
||||
}
|
||||
|
||||
|
||||
pub fn add_member(&self, conn: &diesel::SqliteConnection, new_member: &UserInfo) -> Result<(), UserError> {
|
||||
use crate::schema::user_zone::dsl::*;
|
||||
|
||||
let new_user_zone = UserZone {
|
||||
zone_id: self.id.clone(),
|
||||
user_id: new_member.id.clone()
|
||||
};
|
||||
|
||||
let res = diesel::insert_into(user_zone)
|
||||
.values(new_user_zone)
|
||||
.execute(conn);
|
||||
|
||||
match res {
|
||||
// If user has already access to the zone, safely ignore the conflit
|
||||
// TODO: use 'on conflict do nothing' in postgres when we get there
|
||||
Err(DieselError::DatabaseError(diesel::result::DatabaseErrorKind::UniqueViolation, _)) => (),
|
||||
Err(e) => return Err(e.into()),
|
||||
Ok(_) => ()
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
}
|
462
src/proto/dns.rs
Normal file
462
src/proto/dns.rs
Normal file
|
@ -0,0 +1,462 @@
|
|||
use std::fmt::Write;
|
||||
|
||||
use domain::base::rdata::ComposeRecordData;
|
||||
use domain::base::wire::{Composer, ParseError};
|
||||
use domain::base::{iana::Class, Name, ParseRecordData, ParsedName, RecordData, Record, Rtype, ToName, Ttl};
|
||||
use domain::rdata;
|
||||
use domain::dep::octseq::{Parser, Octets};
|
||||
|
||||
use crate::resources::dns::internal;
|
||||
use crate::errors::Error;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ProtoDnsError {
|
||||
RDataUnknown { input: String, field: String, rtype: String },
|
||||
NameParseError { input: String }
|
||||
}
|
||||
|
||||
/* --------- A --------- */
|
||||
|
||||
impl From<domain::rdata::A> for internal::A {
|
||||
fn from(record_data: domain::rdata::A) -> Self {
|
||||
internal::A { address: record_data.addr() }
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<internal::A> for domain::rdata::A {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(record_data: internal::A) -> Result<Self, Self::Error> {
|
||||
Ok(domain::rdata::A::new(record_data.address))
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- AAAA --------- */
|
||||
|
||||
impl From<domain::rdata::Aaaa> for internal::Aaaa {
|
||||
fn from(record_data: domain::rdata::Aaaa) -> Self {
|
||||
internal::Aaaa { address: record_data.addr() }
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<internal::Aaaa> for domain::rdata::Aaaa {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(record_data: internal::Aaaa) -> Result<Self, Self::Error> {
|
||||
Ok(domain::rdata::Aaaa::new(record_data.address))
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- CNAME --------- */
|
||||
|
||||
impl<N: ToString> From<domain::rdata::Cname<N>> for internal::Cname {
|
||||
fn from(record_data: domain::rdata::Cname<N>) -> Self {
|
||||
internal::Cname { target: internal::Name::new(record_data.cname().to_string()) }
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<internal::Cname> for domain::rdata::Cname<Name<Vec<u8>>> {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(record_data: internal::Cname) -> Result<domain::rdata::Cname<Name<Vec<u8>>>, Self::Error> {
|
||||
let cname = record_data.target.to_string();
|
||||
|
||||
let cname: Name<_> = cname.parse::<Name<_>>().map_err(|e| {
|
||||
Error::from(ProtoDnsError::RDataUnknown {
|
||||
input: cname,
|
||||
field: "target".into(),
|
||||
rtype: "CNAME".into(),
|
||||
}).with_cause(&e.to_string())
|
||||
})?;
|
||||
|
||||
Ok(domain::rdata::Cname::new(cname))
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- MX --------- */
|
||||
|
||||
impl<N: ToString> From<domain::rdata::Mx<N>> for internal::Mx {
|
||||
fn from(record_data: domain::rdata::Mx<N>) -> Self {
|
||||
internal::Mx {
|
||||
preference: record_data.preference(),
|
||||
mail_exchanger: internal::Name::new(record_data.exchange().to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<internal::Mx> for domain::rdata::Mx<Name<Vec<u8>>> {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(record_data: internal::Mx) -> Result<domain::rdata::Mx<Name<Vec<u8>>>, Self::Error> {
|
||||
let mail_exchanger = record_data.mail_exchanger.to_string();
|
||||
|
||||
let mail_exchanger: Name<_> = mail_exchanger.parse::<Name<_>>().map_err(|e| {
|
||||
Error::from(ProtoDnsError::RDataUnknown {
|
||||
input: mail_exchanger,
|
||||
field: "mail_exchanger".into(),
|
||||
rtype: "MX".into(),
|
||||
}).with_cause(&e.to_string())
|
||||
})?;
|
||||
|
||||
Ok(domain::rdata::Mx::new(record_data.preference, mail_exchanger))
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- NS --------- */
|
||||
|
||||
impl<N: ToString> From<domain::rdata::Ns<N>> for internal::Ns {
|
||||
fn from(record_data: domain::rdata::Ns<N>) -> Self {
|
||||
internal::Ns {
|
||||
target: internal::Name::new(record_data.nsdname().to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<internal::Ns> for domain::rdata::Ns<Name<Vec<u8>>> {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(record_data: internal::Ns) -> Result<domain::rdata::Ns<Name<Vec<u8>>>, Self::Error> {
|
||||
let target = record_data.target.to_string();
|
||||
|
||||
let target: Name<_> = target.parse::<Name<_>>().map_err(|e| {
|
||||
Error::from(ProtoDnsError::RDataUnknown {
|
||||
input: target,
|
||||
field: "target".into(),
|
||||
rtype: "NS".into(),
|
||||
}).with_cause(&e.to_string())
|
||||
})?;
|
||||
|
||||
Ok(domain::rdata::Ns::new(target))
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- PTR --------- */
|
||||
|
||||
impl<N: ToString> From<domain::rdata::Ptr<N>> for internal::Ptr {
|
||||
fn from(record_data: domain::rdata::Ptr<N>) -> Self {
|
||||
internal::Ptr {
|
||||
target: internal::Name::new(record_data.ptrdname().to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<internal::Ptr> for domain::rdata::Ptr<Name<Vec<u8>>> {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(record_data: internal::Ptr) -> Result<domain::rdata::Ptr<Name<Vec<u8>>>, Self::Error> {
|
||||
let target = record_data.target.to_string();
|
||||
|
||||
let target: Name<_> = target.parse::<Name<_>>().map_err(|e| {
|
||||
Error::from(ProtoDnsError::RDataUnknown {
|
||||
input: target,
|
||||
field: "target".into(),
|
||||
rtype: "PTR".into(),
|
||||
}).with_cause(&e.to_string())
|
||||
})?;
|
||||
|
||||
Ok(domain::rdata::Ptr::new(target))
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- SOA --------- */
|
||||
|
||||
impl<N: ToString> From<domain::rdata::Soa<N>> for internal::Soa {
|
||||
fn from(record_rdata: domain::rdata::Soa<N>) -> Self {
|
||||
internal::Soa {
|
||||
primary_server: internal::Name::new(record_rdata.mname().to_string()),
|
||||
maintainer: internal::Name::new(record_rdata.rname().to_string()),
|
||||
refresh: record_rdata.refresh().as_secs(),
|
||||
retry: record_rdata.retry().as_secs(),
|
||||
expire: record_rdata.expire().as_secs(),
|
||||
minimum: record_rdata.minimum().as_secs(),
|
||||
serial: record_rdata.serial().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<internal::Soa> for domain::rdata::Soa<Name<Vec<u8>>> {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(record_data: internal::Soa) -> Result<domain::rdata::Soa<Name<Vec<u8>>>, Self::Error> {
|
||||
let primary_server = record_data.primary_server.to_string();
|
||||
let primary_server: Name<_> = primary_server.parse::<Name<_>>().map_err(|e| {
|
||||
Error::from(ProtoDnsError::RDataUnknown {
|
||||
input: primary_server,
|
||||
field: "primary_server".into(),
|
||||
rtype: "SOA".into(),
|
||||
}).with_cause(&e.to_string())
|
||||
})?;
|
||||
|
||||
let maintainer = record_data.maintainer.to_string();
|
||||
let maintainer: Name<_> = maintainer.parse::<Name<_>>().map_err(|e| {
|
||||
Error::from(ProtoDnsError::RDataUnknown {
|
||||
input: maintainer,
|
||||
field: "maintainer".into(),
|
||||
rtype: "SOA".into(),
|
||||
}).with_cause(&e.to_string())
|
||||
})?;
|
||||
|
||||
Ok(domain::rdata::Soa::new(
|
||||
primary_server,
|
||||
maintainer,
|
||||
record_data.serial.into(),
|
||||
Ttl::from_secs(record_data.refresh),
|
||||
Ttl::from_secs(record_data.retry),
|
||||
Ttl::from_secs(record_data.expire),
|
||||
Ttl::from_secs(record_data.minimum),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- SRV --------- */
|
||||
|
||||
impl<N: ToString> From<domain::rdata::Srv<N>> for internal::Srv {
|
||||
fn from(record_data: domain::rdata::Srv<N>) -> Self {
|
||||
internal::Srv {
|
||||
server: internal::Name::new(record_data.target().to_string()),
|
||||
priority: record_data.priority(),
|
||||
weight: record_data.weight(),
|
||||
port: record_data.port(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<internal::Srv> for domain::rdata::Srv<Name<Vec<u8>>> {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(record_data: internal::Srv) -> Result<domain::rdata::Srv<Name<Vec<u8>>>, Self::Error> {
|
||||
let server = record_data.server.to_string();
|
||||
let server: Name<_> = server.parse::<Name<_>>().map_err(|e| {
|
||||
Error::from(ProtoDnsError::RDataUnknown {
|
||||
input: server,
|
||||
field: "server".into(),
|
||||
rtype: "SRV".into(),
|
||||
}).with_cause(&e.to_string())
|
||||
})?;
|
||||
|
||||
|
||||
Ok(domain::rdata::Srv::new(
|
||||
record_data.priority,
|
||||
record_data.weight,
|
||||
record_data.port,
|
||||
server
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- TXT --------- */
|
||||
|
||||
impl<O: AsRef<[u8]>> From<domain::rdata::Txt<O>> for internal::Txt {
|
||||
fn from(record_data: rdata::Txt<O>) -> Self {
|
||||
let concatenated_text: Vec<_> = record_data.iter()
|
||||
.flatten()
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
internal::Txt {
|
||||
text: concatenated_text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<internal::Txt> for domain::rdata::Txt<Vec<u8>> {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(record_data: internal::Txt) -> Result<domain::rdata::Txt<Vec<u8>>, Self::Error> {
|
||||
let txt: domain::rdata::Txt<_> = rdata::Txt::build_from_slice(&record_data.text).map_err(|e| {
|
||||
let text = record_data.text.iter()
|
||||
.fold(String::new(), |mut output, b| {
|
||||
write!(output, "{:02X}", b).unwrap();
|
||||
output
|
||||
});
|
||||
|
||||
Error::from(ProtoDnsError::RDataUnknown {
|
||||
input: format!("0x{}", text),
|
||||
field: "text".into(),
|
||||
rtype: "TXT".into(),
|
||||
}).with_cause(&e.to_string())
|
||||
})?;
|
||||
|
||||
|
||||
Ok(txt)
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- ParsedRData --------- */
|
||||
|
||||
pub enum ParsedRData<Name, Octs> {
|
||||
A(domain::rdata::A),
|
||||
Aaaa(domain::rdata::Aaaa),
|
||||
Cname(domain::rdata::Cname<Name>),
|
||||
Mx(domain::rdata::Mx<Name>),
|
||||
Ns(domain::rdata::Ns<Name>),
|
||||
Ptr(domain::rdata::Ptr<Name>),
|
||||
Soa(domain::rdata::Soa<Name>),
|
||||
Srv(domain::rdata::Srv<Name>),
|
||||
Txt(domain::rdata::Txt<Octs>),
|
||||
}
|
||||
|
||||
impl<Name: ToString, Octs: AsRef<[u8]>> From<ParsedRData<Name, Octs>> for internal::RData {
|
||||
fn from(value: ParsedRData<Name, Octs>) -> Self {
|
||||
match value {
|
||||
ParsedRData::A(record_rdata) => internal::RData::A(record_rdata.into()),
|
||||
ParsedRData::Aaaa(record_rdata) => internal::RData::Aaaa(record_rdata.into()),
|
||||
ParsedRData::Cname(record_rdata) => internal::RData::Cname(record_rdata.into()),
|
||||
ParsedRData::Mx(record_rdata) => internal::RData::Mx(record_rdata.into()),
|
||||
ParsedRData::Ns(record_rdata) => internal::RData::Ns(record_rdata.into()),
|
||||
ParsedRData::Ptr(record_rdata) => internal::RData::Ptr(record_rdata.into()),
|
||||
ParsedRData::Soa(record_rdata) => internal::RData::Soa(record_rdata.into()),
|
||||
ParsedRData::Srv(record_rdata) => internal::RData::Srv(record_rdata.into()),
|
||||
ParsedRData::Txt(record_rdata) => internal::RData::Txt(record_rdata.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<internal::RData> for ParsedRData<Name<Vec<u8>>, Vec<u8>> {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: internal::RData) -> Result<Self, Self::Error> {
|
||||
let rdata = match value {
|
||||
internal::RData::A(record_rdata) => ParsedRData::A(record_rdata.try_into()?),
|
||||
internal::RData::Aaaa(record_rdata) => ParsedRData::Aaaa(record_rdata.try_into()?),
|
||||
internal::RData::Cname(record_rdata) => ParsedRData::Cname(record_rdata.try_into()?),
|
||||
internal::RData::Mx(record_rdata) => ParsedRData::Mx(record_rdata.try_into()?),
|
||||
internal::RData::Ns(record_rdata) => ParsedRData::Ns(record_rdata.try_into()?),
|
||||
internal::RData::Ptr(record_rdata) => ParsedRData::Ptr(record_rdata.try_into()?),
|
||||
internal::RData::Soa(record_rdata) => ParsedRData::Soa(record_rdata.try_into()?),
|
||||
internal::RData::Srv(record_rdata) => ParsedRData::Srv(record_rdata.try_into()?),
|
||||
internal::RData::Txt(record_rdata) => ParsedRData::Txt(record_rdata.try_into()?),
|
||||
};
|
||||
Ok(rdata)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl<Name, Octs> ParsedRData<Name, Octs> {
|
||||
pub fn rtype(&self) -> Rtype {
|
||||
match self {
|
||||
ParsedRData::A(_) => Rtype::A,
|
||||
ParsedRData::Aaaa(_) => Rtype::AAAA,
|
||||
ParsedRData::Cname(_) => Rtype::CNAME,
|
||||
ParsedRData::Mx(_) => Rtype::MX,
|
||||
ParsedRData::Ns(_) => Rtype::NS,
|
||||
ParsedRData::Ptr(_) => Rtype::PTR,
|
||||
ParsedRData::Soa(_) => Rtype::SOA,
|
||||
ParsedRData::Srv(_) => Rtype::SRV,
|
||||
ParsedRData::Txt(_) => Rtype::TXT,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<Name, Octs> RecordData for ParsedRData<Name, Octs> {
|
||||
fn rtype(&self) -> Rtype {
|
||||
ParsedRData::rtype(self)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, Octs: Octets + ?Sized> ParseRecordData<'a, Octs> for ParsedRData<ParsedName<Octs::Range<'a>>, Octs::Range<'a>> {
|
||||
fn parse_rdata(
|
||||
rtype: Rtype,
|
||||
parser: &mut Parser<'a, Octs>,
|
||||
) -> Result<Option<Self>, ParseError> {
|
||||
let record = match rtype {
|
||||
Rtype::A => ParsedRData::A(rdata::A::parse(parser)?),
|
||||
Rtype::AAAA => ParsedRData::Aaaa(rdata::Aaaa::parse(parser)?),
|
||||
Rtype::CNAME => ParsedRData::Cname(rdata::Cname::parse(parser)?),
|
||||
Rtype::MX => ParsedRData::Mx(rdata::Mx::parse(parser)?),
|
||||
Rtype::NS => ParsedRData::Ns(rdata::Ns::parse(parser)?),
|
||||
Rtype::PTR => ParsedRData::Ptr(rdata::Ptr::parse(parser)?),
|
||||
Rtype::SOA => ParsedRData::Soa(rdata::Soa::parse(parser)?),
|
||||
Rtype::SRV => ParsedRData::Srv(rdata::Srv::parse(parser)?),
|
||||
Rtype::TXT => ParsedRData::Txt(rdata::Txt::parse(parser)?),
|
||||
_ => return Ok(None)
|
||||
};
|
||||
|
||||
Ok(Some(record))
|
||||
}
|
||||
}
|
||||
|
||||
impl<Name: ToName, Octs: AsRef<[u8]>> ComposeRecordData for ParsedRData<Name, Octs> {
|
||||
fn rdlen(&self, compress: bool) -> Option<u16> {
|
||||
match self {
|
||||
ParsedRData::A(record_rdata) => record_rdata.rdlen(compress),
|
||||
ParsedRData::Aaaa(record_rdata) => record_rdata.rdlen(compress),
|
||||
ParsedRData::Cname(record_rdata) => record_rdata.rdlen(compress),
|
||||
ParsedRData::Mx(record_rdata) => record_rdata.rdlen(compress),
|
||||
ParsedRData::Ns(record_rdata) => record_rdata.rdlen(compress),
|
||||
ParsedRData::Ptr(record_rdata) => record_rdata.rdlen(compress),
|
||||
ParsedRData::Soa(record_rdata) => record_rdata.rdlen(compress),
|
||||
ParsedRData::Srv(record_rdata) => record_rdata.rdlen(compress),
|
||||
ParsedRData::Txt(record_rdata) => record_rdata.rdlen(compress),
|
||||
}
|
||||
}
|
||||
|
||||
fn compose_rdata<Target: Composer + ?Sized>(
|
||||
&self,
|
||||
target: &mut Target,
|
||||
) -> Result<(), Target::AppendError> {
|
||||
match self {
|
||||
ParsedRData::A(record_rdata) => record_rdata.compose_rdata(target),
|
||||
ParsedRData::Aaaa(record_rdata) => record_rdata.compose_rdata(target),
|
||||
ParsedRData::Cname(record_rdata) => record_rdata.compose_rdata(target),
|
||||
ParsedRData::Mx(record_rdata) => record_rdata.compose_rdata(target),
|
||||
ParsedRData::Ns(record_rdata) => record_rdata.compose_rdata(target),
|
||||
ParsedRData::Ptr(record_rdata) => record_rdata.compose_rdata(target),
|
||||
ParsedRData::Soa(record_rdata) => record_rdata.compose_rdata(target),
|
||||
ParsedRData::Srv(record_rdata) => record_rdata.compose_rdata(target),
|
||||
ParsedRData::Txt(record_rdata) => record_rdata.compose_rdata(target),
|
||||
}
|
||||
}
|
||||
|
||||
fn compose_canonical_rdata<Target: Composer + ?Sized>(
|
||||
&self,
|
||||
target: &mut Target,
|
||||
) -> Result<(), Target::AppendError> {
|
||||
match self {
|
||||
ParsedRData::A(record_rdata) => record_rdata.compose_canonical_rdata(target),
|
||||
ParsedRData::Aaaa(record_rdata) => record_rdata.compose_canonical_rdata(target),
|
||||
ParsedRData::Cname(record_rdata) => record_rdata.compose_canonical_rdata(target),
|
||||
ParsedRData::Mx(record_rdata) => record_rdata.compose_canonical_rdata(target),
|
||||
ParsedRData::Ns(record_rdata) => record_rdata.compose_canonical_rdata(target),
|
||||
ParsedRData::Ptr(record_rdata) => record_rdata.compose_canonical_rdata(target),
|
||||
ParsedRData::Soa(record_rdata) => record_rdata.compose_canonical_rdata(target),
|
||||
ParsedRData::Srv(record_rdata) => record_rdata.compose_canonical_rdata(target),
|
||||
ParsedRData::Txt(record_rdata) => record_rdata.compose_canonical_rdata(target),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- Records --------- */
|
||||
|
||||
pub(crate) type RecordImpl = Record<
|
||||
Name<Vec<u8>>,
|
||||
ParsedRData<Name<Vec<u8>>,Vec<u8>>
|
||||
>;
|
||||
|
||||
|
||||
impl<Name: ToString, Oct: AsRef<[u8]>> From<Record<Name, ParsedRData<Name, Oct>>> for internal::Record {
|
||||
fn from(value: Record<Name, ParsedRData<Name, Oct>>) -> Self {
|
||||
internal::Record {
|
||||
name: internal::Name::new(value.owner().to_string()),
|
||||
ttl: value.ttl().as_secs(),
|
||||
rdata: internal::RData::from(value.into_data()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<internal::Record> for RecordImpl {
|
||||
type Error = Error;
|
||||
|
||||
fn try_from(value: internal::Record) -> Result<Self, Self::Error> {
|
||||
let owner = value.name.to_string();
|
||||
let owner = owner.parse::<Name<_>>().map_err(|e| {
|
||||
Error::from(ProtoDnsError::NameParseError {
|
||||
input: owner
|
||||
}).with_cause(&e.to_string())
|
||||
})?;
|
||||
|
||||
let ttl = Ttl::from_secs(value.ttl);
|
||||
let data = value.rdata.try_into()?;
|
||||
Ok(Record::new(owner, Class::IN, ttl, data))
|
||||
}
|
||||
}
|
1
src/proto/mod.rs
Normal file
1
src/proto/mod.rs
Normal file
|
@ -0,0 +1 @@
|
|||
pub mod dns;
|
4
src/resources/dns/external/mod.rs
vendored
Normal file
4
src/resources/dns/external/mod.rs
vendored
Normal file
|
@ -0,0 +1,4 @@
|
|||
pub mod rdata;
|
||||
pub mod record;
|
||||
|
||||
pub use record::*;
|
430
src/resources/dns/external/rdata.rs
vendored
Normal file
430
src/resources/dns/external/rdata.rs
vendored
Normal file
|
@ -0,0 +1,430 @@
|
|||
use std::fmt::Write;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use domain::base::scan::Symbol;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::errors::Error;
|
||||
use crate::validation;
|
||||
|
||||
use crate::macros::{append_errors, push_error};
|
||||
use crate::resources::dns::internal;
|
||||
|
||||
pub enum RDataValidationError {
|
||||
Ip4Address { input: String },
|
||||
Ip6Address { input: String },
|
||||
IpAddress { input: String },
|
||||
Number { min: i128, max: i128 }
|
||||
}
|
||||
|
||||
/// Type used to serialize / deserialize resource records data to response / request
|
||||
///
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(tag = "type", content = "rdata")]
|
||||
#[serde(rename_all = "UPPERCASE")]
|
||||
pub enum RData {
|
||||
A(A),
|
||||
Aaaa(Aaaa),
|
||||
// TODO: CAA
|
||||
Cname(Cname),
|
||||
// TODO: DS
|
||||
Mx(Mx),
|
||||
Ns(Ns),
|
||||
Ptr(Ptr),
|
||||
Soa(Soa),
|
||||
Srv(Srv),
|
||||
// TODO: SSHFP
|
||||
// TODO: SVCB / HTTPS
|
||||
// TODO: TLSA
|
||||
Txt(Txt),
|
||||
}
|
||||
|
||||
impl RData {
|
||||
pub fn validate(self) -> Result<internal::RData, Vec<Error>> {
|
||||
let rdata = match self {
|
||||
RData::A(data) => internal::RData::A(data.validate()?),
|
||||
RData::Aaaa(data) => internal::RData::Aaaa(data.validate()?),
|
||||
RData::Cname(data) => internal::RData::Cname(data.validate()?),
|
||||
RData::Mx(data) => internal::RData::Mx(data.validate()?),
|
||||
RData::Ns(data) => internal::RData::Ns(data.validate()?),
|
||||
RData::Ptr(data) => internal::RData::Ptr(data.validate()?),
|
||||
RData::Soa(data) => internal::RData::Soa(data.validate()?),
|
||||
RData::Srv(data) => internal::RData::Srv(data.validate()?),
|
||||
RData::Txt(data) => internal::RData::Txt(data.validate()?),
|
||||
};
|
||||
|
||||
Ok(rdata)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<internal::RData> for RData {
|
||||
fn from(value: internal::RData) -> Self {
|
||||
match value {
|
||||
internal::RData::A(data) => RData::A(data.into()),
|
||||
internal::RData::Aaaa(data) => RData::Aaaa(data.into()),
|
||||
internal::RData::Cname(data) => RData::Cname(data.into()),
|
||||
internal::RData::Mx(data) => RData::Mx(data.into()),
|
||||
internal::RData::Ns(data) => RData::Ns(data.into()),
|
||||
internal::RData::Ptr(data) => RData::Ptr(data.into()),
|
||||
internal::RData::Soa(data) => RData::Soa(data.into()),
|
||||
internal::RData::Srv(data) => RData::Srv(data.into()),
|
||||
internal::RData::Txt(data) => RData::Txt(data.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- A --------- */
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct A {
|
||||
pub address: String,
|
||||
}
|
||||
|
||||
impl From<internal::A> for A {
|
||||
fn from(value: internal::A) -> Self {
|
||||
A {
|
||||
address: value.address.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl A {
|
||||
pub fn validate(self) -> Result<internal::A, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
// TODO: replace with custom validation
|
||||
let address = push_error!(self.address.parse::<Ipv4Addr>().map_err(|e| {
|
||||
Error::from(RDataValidationError::Ip4Address { input: self.address })
|
||||
.with_cause(&e.to_string())
|
||||
.with_path("/address")
|
||||
}), errors);
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(internal::A {
|
||||
address: address.unwrap()
|
||||
})
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- AAAA --------- */
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Aaaa {
|
||||
pub address: String,
|
||||
}
|
||||
|
||||
impl From<internal::Aaaa> for Aaaa {
|
||||
fn from(value: internal::Aaaa) -> Self {
|
||||
Aaaa {
|
||||
address: value.address.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Aaaa {
|
||||
pub fn validate(self) -> Result<internal::Aaaa, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
// TODO: replace with custom validation
|
||||
let address = push_error!(self.address.parse::<Ipv6Addr>().map_err(|e| {
|
||||
Error::from(RDataValidationError::Ip6Address { input: self.address })
|
||||
.with_cause(&e.to_string())
|
||||
.with_path("/address")
|
||||
}), errors);
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(internal::Aaaa {
|
||||
address: address.unwrap()
|
||||
})
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- CNAME --------- */
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Cname {
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
impl From<internal::Cname> for Cname {
|
||||
fn from(value: internal::Cname) -> Self {
|
||||
Cname {
|
||||
target: value.target.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Cname {
|
||||
pub fn validate(self) -> Result<internal::Cname, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let cname = push_error!(
|
||||
validation::normalize_domain(&self.target),
|
||||
errors, "/target"
|
||||
);
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(internal::Cname {
|
||||
target: internal::Name::new(cname.unwrap())
|
||||
})
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- MX --------- */
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Mx {
|
||||
// TODO: Validate number
|
||||
pub preference: u16,
|
||||
pub mail_exchanger: String,
|
||||
}
|
||||
|
||||
impl From<internal::Mx> for Mx {
|
||||
fn from(value: internal::Mx) -> Self {
|
||||
Mx {
|
||||
preference: value.preference,
|
||||
mail_exchanger: value.mail_exchanger.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Mx {
|
||||
pub fn validate(self) -> Result<internal::Mx, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let mail_exchanger = push_error!(
|
||||
validation::normalize_domain(&self.mail_exchanger),
|
||||
errors, "/mail_exchanger"
|
||||
);
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(internal::Mx {
|
||||
preference: self.preference,
|
||||
mail_exchanger: internal::Name::new(mail_exchanger.unwrap()),
|
||||
})
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- NS --------- */
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Ns {
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
impl From<internal::Ns> for Ns {
|
||||
fn from(value: internal::Ns) -> Self {
|
||||
Ns {
|
||||
target: value.target.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ns {
|
||||
pub fn validate(self) -> Result<internal::Ns, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let target = push_error!(
|
||||
validation::normalize_domain(&self.target),
|
||||
errors, "/target"
|
||||
);
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(internal::Ns {
|
||||
target: internal::Name::new(target.unwrap()),
|
||||
})
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- PTR --------- */
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Ptr {
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
impl From<internal::Ptr> for Ptr {
|
||||
fn from(value: internal::Ptr) -> Self {
|
||||
Ptr {
|
||||
target: value.target.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ptr {
|
||||
pub fn validate(self) -> Result<internal::Ptr, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let target = push_error!(
|
||||
validation::normalize_domain(&self.target),
|
||||
errors, "/target"
|
||||
);
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(internal::Ptr {
|
||||
target: internal::Name::new(target.unwrap()),
|
||||
})
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* --------- SOA --------- */
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Soa {
|
||||
pub primary_server: String,
|
||||
pub maintainer: String,
|
||||
pub refresh: u32,
|
||||
pub retry: u32,
|
||||
pub expire: u32,
|
||||
pub minimum: u32,
|
||||
pub serial: u32,
|
||||
}
|
||||
|
||||
impl From<internal::Soa> for Soa {
|
||||
fn from(value: internal::Soa) -> Self {
|
||||
Soa {
|
||||
primary_server: value.primary_server.to_string(),
|
||||
maintainer: value.maintainer.to_string(),
|
||||
refresh: value.refresh,
|
||||
retry: value.retry,
|
||||
expire: value.expire,
|
||||
minimum: value.minimum,
|
||||
serial: value.serial,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Soa {
|
||||
pub fn validate(self) -> Result<internal::Soa, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let primary_server = push_error!(
|
||||
validation::normalize_domain(&self.primary_server),
|
||||
errors, "/primary_server"
|
||||
);
|
||||
|
||||
let maintainer = push_error!(
|
||||
validation::normalize_domain(&self.maintainer),
|
||||
errors, "/maintainer"
|
||||
);
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(internal::Soa {
|
||||
primary_server: internal::Name::new(primary_server.unwrap()),
|
||||
maintainer: internal::Name::new(maintainer.unwrap()),
|
||||
refresh: self.refresh,
|
||||
retry: self.retry,
|
||||
expire: self.expire,
|
||||
minimum: self.minimum,
|
||||
serial: self.serial,
|
||||
})
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- SRV --------- */
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Srv {
|
||||
pub server: String,
|
||||
pub port: u16,
|
||||
pub priority: u16,
|
||||
pub weight: u16,
|
||||
}
|
||||
|
||||
impl From<internal::Srv> for Srv {
|
||||
fn from(value: internal::Srv) -> Self {
|
||||
Srv {
|
||||
server: value.server.to_string(),
|
||||
port: value.port,
|
||||
priority: value.priority,
|
||||
weight: value.weight,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Srv {
|
||||
pub fn validate(self) -> Result<internal::Srv, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let server = push_error!(
|
||||
validation::normalize_domain(&self.server),
|
||||
errors, "/server"
|
||||
);
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(internal::Srv {
|
||||
server: internal::Name::new(server.unwrap()),
|
||||
priority: self.priority,
|
||||
weight: self.weight,
|
||||
port: self.port,
|
||||
})
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* --------- TXT --------- */
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Txt {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl From<internal::Txt> for Txt {
|
||||
fn from(value: internal::Txt) -> Self {
|
||||
|
||||
let mut concatenated_text = String::new();
|
||||
for c in value.text.iter() {
|
||||
// Escapes '\' and non printable chars
|
||||
let c = Symbol::display_from_octet(*c);
|
||||
write!(concatenated_text, "{}", c).unwrap();
|
||||
}
|
||||
|
||||
Txt {
|
||||
text: concatenated_text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Txt {
|
||||
pub fn validate(self) -> Result<internal::Txt, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let text = append_errors!(
|
||||
validation::parse_txt_data(&self.text),
|
||||
errors, "/text"
|
||||
);
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(internal::Txt {
|
||||
text: text.unwrap()
|
||||
})
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
119
src/resources/dns/external/record.rs
vendored
Normal file
119
src/resources/dns/external/record.rs
vendored
Normal file
|
@ -0,0 +1,119 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::rdata::RData;
|
||||
use crate::resources::dns::internal;
|
||||
use crate::{errors::Error, validation};
|
||||
use crate::macros::{append_errors, push_error};
|
||||
|
||||
pub enum RecordError {
|
||||
Validation { suberrors: Vec<Error> },
|
||||
}
|
||||
|
||||
pub enum RecordValidationError {
|
||||
NotInZone { name: String, zone: String },
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Record {
|
||||
pub name: String,
|
||||
pub ttl: u32,
|
||||
#[serde(flatten)]
|
||||
pub rdata: RData
|
||||
}
|
||||
|
||||
impl From<internal::Record> for Record {
|
||||
fn from(value: internal::Record) -> Self {
|
||||
Record {
|
||||
name: value.name.to_string(),
|
||||
ttl: value.ttl,
|
||||
rdata: value.rdata.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Record {
|
||||
fn validate(self, zone_name: &internal::Name) -> Result<internal::Record, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let name = push_error!(validation::normalize_domain(&self.name), errors, "/name").map(internal::Name::new);
|
||||
|
||||
let name = name.and_then(|name| {
|
||||
if !name.ends_with(zone_name) {
|
||||
errors.push(
|
||||
Error::from(RecordValidationError::NotInZone { name: self.name, zone: zone_name.to_string() })
|
||||
.with_path("/name")
|
||||
);
|
||||
None
|
||||
} else {
|
||||
Some(name)
|
||||
}
|
||||
});
|
||||
// TODO: validate ttl
|
||||
let rdata = append_errors!(self.rdata.validate(), errors, "/rdata");
|
||||
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(internal::Record {
|
||||
name: name.unwrap(),
|
||||
ttl: self.ttl,
|
||||
rdata: rdata.unwrap(),
|
||||
})
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct RecordList(pub Vec<Record>);
|
||||
|
||||
impl From<internal::RecordList> for RecordList {
|
||||
fn from(value: internal::RecordList) -> Self {
|
||||
let records = value.records.into_iter().map(Record::from).collect();
|
||||
RecordList(records)
|
||||
}
|
||||
}
|
||||
|
||||
impl RecordList {
|
||||
fn validate(self, zone_name: &internal::Name) -> Result<internal::RecordList, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
let mut records = Vec::new();
|
||||
|
||||
for (index, record) in self.0.into_iter().enumerate() {
|
||||
let record = append_errors!(record.validate(zone_name), errors, &format!("/{index}"));
|
||||
|
||||
if let Some(record) = record {
|
||||
records.push(record)
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(internal::RecordList { records })
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug,Deserialize)]
|
||||
pub struct AddRecords {
|
||||
pub new_records: RecordList
|
||||
}
|
||||
|
||||
impl AddRecords {
|
||||
pub fn validate(self, zone_name: &internal::Name) -> Result<internal::AddRecords, Error> {
|
||||
let mut errors = Vec::new();
|
||||
let records = append_errors!(self.new_records.validate(zone_name), errors, "/new_records");
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(internal::AddRecords {
|
||||
new_records: records.unwrap(),
|
||||
})
|
||||
} else {
|
||||
Err(Error::from(RecordError::Validation { suberrors: errors }))
|
||||
}
|
||||
}
|
||||
}
|
207
src/resources/dns/friendly/base.rs
Normal file
207
src/resources/dns/friendly/base.rs
Normal file
|
@ -0,0 +1,207 @@
|
|||
use std::net::IpAddr;
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::errors::Error;
|
||||
use crate::macros::{push_error, append_errors, check_type};
|
||||
use crate::resources::dns::external::rdata::RDataValidationError;
|
||||
use crate::resources::dns::internal::base::{Name, Text};
|
||||
use crate::validation;
|
||||
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ValueType {
|
||||
Object,
|
||||
Array,
|
||||
String,
|
||||
Number,
|
||||
Null,
|
||||
Bool,
|
||||
}
|
||||
|
||||
impl ValueType {
|
||||
pub fn from_value(value: &Value) -> ValueType {
|
||||
match value {
|
||||
Value::Array(_) => ValueType::Array,
|
||||
Value::Bool(_) => ValueType::Bool,
|
||||
Value::Null => ValueType::Null,
|
||||
Value::Number(_) => ValueType::Number,
|
||||
Value::Object(_) => ValueType::Object,
|
||||
Value::String(_) => ValueType::String,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum InputDataError {
|
||||
TypeError { expected: ValueType, found: ValueType },
|
||||
MissingValue,
|
||||
}
|
||||
|
||||
pub trait FromValue: Sized {
|
||||
fn from_value(value: Value) -> Result<Self, Vec<Error>>;
|
||||
}
|
||||
|
||||
impl FromValue for Name {
|
||||
fn from_value(value: Value) -> Result<Self, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let value = check_type!(value, String, errors);
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(errors);
|
||||
}
|
||||
|
||||
let name = push_error!(
|
||||
validation::normalize_domain(&value.unwrap()),
|
||||
errors
|
||||
);
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(Name::new(name.unwrap()))
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromValue for IpAddr {
|
||||
fn from_value(value: Value) -> Result<Self, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let address = check_type!(value, String, errors);
|
||||
|
||||
let address = if let Some(address) = address {
|
||||
// TODO: replace with custom validation
|
||||
push_error!(address.parse::<IpAddr>().map_err(|e| {
|
||||
Error::from(RDataValidationError::IpAddress { input: address })
|
||||
.with_cause(&e.to_string())
|
||||
}), errors)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(address.unwrap())
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromValue for u32 {
|
||||
fn from_value(value: Value) -> Result<Self, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let number = check_type!(value, Number, errors);
|
||||
|
||||
let address = if let Some(number) = number {
|
||||
push_error!(
|
||||
number.as_u64()
|
||||
.ok_or(Error::from(RDataValidationError::Number { min: u32::MIN.into(), max: u32::MAX.into()}))
|
||||
.and_then(|number| {
|
||||
u32::try_from(number).map_err(|e| {
|
||||
Error::from(RDataValidationError::Number { min: u32::MIN.into(), max: u32::MAX.into()})
|
||||
.with_cause(&e.to_string())
|
||||
})
|
||||
}),
|
||||
errors
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(address.unwrap())
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromValue for u16 {
|
||||
fn from_value(value: Value) -> Result<Self, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let number = check_type!(value, Number, errors);
|
||||
|
||||
let address = if let Some(number) = number {
|
||||
push_error!(
|
||||
number.as_u64()
|
||||
.ok_or(Error::from(RDataValidationError::Number { min: u16::MIN.into(), max: u16::MAX.into()}))
|
||||
.and_then(|number| {
|
||||
u16::try_from(number).map_err(|e| {
|
||||
Error::from(RDataValidationError::Number { min: u16::MIN.into(), max: u16::MAX.into()})
|
||||
.with_cause(&e.to_string())
|
||||
})
|
||||
}),
|
||||
errors
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(address.unwrap())
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: FromValue> FromValue for Vec<T> {
|
||||
fn from_value(value: serde_json::Value) -> Result<Self, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let array = check_type!(value, Array, errors);
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(errors);
|
||||
}
|
||||
let array = array.unwrap();
|
||||
|
||||
let mut list = Vec::new();
|
||||
|
||||
for (index, item) in array.into_iter().enumerate() {
|
||||
let res = append_errors!(
|
||||
T::from_value(item),
|
||||
errors,
|
||||
&format!("/{index}")
|
||||
);
|
||||
|
||||
if let Some(item) = res {
|
||||
list.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(list)
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromValue for Text {
|
||||
fn from_value(value: Value) -> Result<Self, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let data = check_type!(value, String, errors);
|
||||
|
||||
let data = if let Some(data) = data {
|
||||
append_errors!(validation::parse_txt_data(&data), errors)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(Text ::new(data.unwrap()))
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
271
src/resources/dns/friendly/create.rs
Normal file
271
src/resources/dns/friendly/create.rs
Normal file
|
@ -0,0 +1,271 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::validation;
|
||||
use crate::errors::Error;
|
||||
use crate::macros::{append_errors, check_type, get_object_value, push_error};
|
||||
use crate::resources::dns::internal::{self, Name, Record};
|
||||
|
||||
use super::{rdata, FriendlyRType};
|
||||
use super::FromValue;
|
||||
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum ConfigurationType {
|
||||
Mail,
|
||||
Web,
|
||||
}
|
||||
|
||||
impl ConfigurationType {
|
||||
pub fn get_records(&self, input_data: serde_json::Value, default_ttl: u32, zone_name: Name) -> Result<Vec<Record>, Vec<Error>> {
|
||||
match self {
|
||||
ConfigurationType::Mail => {
|
||||
NewSectionMail::from_value(input_data.clone())
|
||||
.map(|section| section.internal(default_ttl, zone_name))
|
||||
},
|
||||
ConfigurationType::Web => {
|
||||
NewSectionWeb::from_value(input_data.clone())
|
||||
.map(|section| section.internal(default_ttl, zone_name))
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct NewRecordQuery {
|
||||
pub name: Option<String>,
|
||||
pub config: Option<ConfigurationType>,
|
||||
pub rtype: Option<FriendlyRType>,
|
||||
}
|
||||
|
||||
impl NewRecordQuery {
|
||||
pub fn validate(&mut self, zone_name: &str) -> Result<(), Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
if let Some(input_name) = &self.name {
|
||||
if input_name.is_empty() {
|
||||
self.name = Some(zone_name.to_string());
|
||||
} else {
|
||||
let new_name = format!("{input_name}.{zone_name}");
|
||||
let new_name = push_error!(
|
||||
validation::normalize_domain(&new_name),
|
||||
errors,
|
||||
"/name"
|
||||
);
|
||||
// Keep the old value if the name is invalid
|
||||
if new_name.is_some() {
|
||||
self.name = new_name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ToInternal {
|
||||
fn internal(self, ttl: u32, node_name: Name) -> Vec<internal::Record>;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NewRecord<T> {
|
||||
ttl: Option<u32>,
|
||||
data: Option<T>,
|
||||
}
|
||||
|
||||
impl<T: FromValue> FromValue for NewRecord<T> {
|
||||
fn from_value(value: serde_json::Value) -> Result<Self, Vec<crate::errors::Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let object = check_type!(value, Object, errors);
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(errors);
|
||||
}
|
||||
|
||||
let mut object = object.unwrap();
|
||||
|
||||
let ttl = object.remove("ttl");
|
||||
let ttl = if let Some(ttl) = ttl {
|
||||
append_errors!(FromValue::from_value(ttl), errors, "/ttl")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let data = object.remove("data");
|
||||
let data = if let Some(data) = data {
|
||||
append_errors!(FromValue::from_value(data), errors, "/data")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(NewRecord {
|
||||
ttl,
|
||||
data,
|
||||
})
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ToInternal> ToInternal for NewRecord<T> {
|
||||
fn internal(self, ttl: u32, node_name: Name) -> Vec<internal::Record> {
|
||||
let ttl = self.ttl.unwrap_or(ttl);
|
||||
|
||||
self.data
|
||||
.map(|data| data.internal(ttl, node_name))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NewRequiredRecord<T> {
|
||||
ttl: Option<u32>,
|
||||
data: T,
|
||||
}
|
||||
|
||||
impl<T: FromValue> FromValue for NewRequiredRecord<T> {
|
||||
fn from_value(value: serde_json::Value) -> Result<Self, Vec<crate::errors::Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let object = check_type!(value, Object, errors);
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(errors);
|
||||
}
|
||||
|
||||
let mut object = object.unwrap();
|
||||
|
||||
let ttl = object.remove("ttl");
|
||||
let ttl = if let Some(ttl) = ttl {
|
||||
append_errors!(FromValue::from_value(ttl), errors, "/ttl")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let data = get_object_value!(object, "data", errors);
|
||||
let data = if let Some(data) = data {
|
||||
append_errors!(FromValue::from_value(data), errors, "/data")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(NewRequiredRecord {
|
||||
ttl,
|
||||
data: data.unwrap(),
|
||||
})
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ToInternal> ToInternal for NewRequiredRecord<T> {
|
||||
fn internal(self, ttl: u32, node_name: Name) -> Vec<internal::Record> {
|
||||
let ttl = self.ttl.unwrap_or(ttl);
|
||||
|
||||
self.data.internal(ttl, node_name)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NewSectionWeb {
|
||||
addresses: NewRequiredRecord<rdata::Addresses>,
|
||||
}
|
||||
|
||||
impl FromValue for NewSectionWeb {
|
||||
fn from_value(value: serde_json::Value) -> Result<Self, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let object = check_type!(value, Object, errors);
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(errors);
|
||||
}
|
||||
|
||||
let mut object = object.unwrap();
|
||||
|
||||
let addresses = get_object_value!(object, "addresses", errors);
|
||||
let addresses = if let Some(addresses) = addresses {
|
||||
append_errors!(FromValue::from_value(addresses), errors, "/addresses")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(NewSectionWeb {
|
||||
addresses: addresses.unwrap(),
|
||||
})
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
impl ToInternal for NewSectionWeb {
|
||||
fn internal(self, ttl: u32, node_name: Name) -> Vec<internal::Record> {
|
||||
self.addresses.internal(ttl, node_name)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NewSectionMail {
|
||||
mailservers: NewRequiredRecord<rdata::MailServers>,
|
||||
spf: NewRecord<rdata::Spf>,
|
||||
}
|
||||
|
||||
impl FromValue for NewSectionMail {
|
||||
fn from_value(value: serde_json::Value) -> Result<Self, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let object = check_type!(value, Object, errors);
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(errors);
|
||||
}
|
||||
|
||||
let mut object = object.unwrap();
|
||||
|
||||
let mailservers = get_object_value!(object, "mailservers", errors);
|
||||
let mailservers = if let Some(mailservers) = mailservers {
|
||||
append_errors!(FromValue::from_value(mailservers), errors, "/mailservers")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let spf = get_object_value!(object, "spf", errors);
|
||||
let spf = if let Some(spf) = spf {
|
||||
append_errors!(FromValue::from_value(spf), errors, "/spf")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(NewSectionMail {
|
||||
mailservers: mailservers.unwrap(),
|
||||
spf: spf.unwrap()
|
||||
})
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
impl ToInternal for NewSectionMail {
|
||||
fn internal(self, ttl: u32, node_name: Name) -> Vec<internal::Record> {
|
||||
let mut records = self.mailservers.internal(ttl, node_name.clone());
|
||||
records.extend(self.spf.internal(ttl, node_name));
|
||||
records
|
||||
}
|
||||
}
|
9
src/resources/dns/friendly/mod.rs
Normal file
9
src/resources/dns/friendly/mod.rs
Normal file
|
@ -0,0 +1,9 @@
|
|||
pub mod rdata;
|
||||
pub mod record;
|
||||
pub mod base;
|
||||
pub mod create;
|
||||
|
||||
pub use rdata::*;
|
||||
pub use record::*;
|
||||
pub use base::*;
|
||||
pub use create::*;
|
324
src/resources/dns/friendly/rdata.rs
Normal file
324
src/resources/dns/friendly/rdata.rs
Normal file
|
@ -0,0 +1,324 @@
|
|||
use std::hash::Hash;
|
||||
use std::net::IpAddr;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::errors::Error;
|
||||
use crate::resources::dns::internal::{self, Name, Text};
|
||||
use crate::macros::{append_errors, check_type, get_object_value};
|
||||
|
||||
use super::{FromValue, ToInternal};
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub enum FriendlyRData {
|
||||
Address(Address),
|
||||
Alias(Alias),
|
||||
MailServer(MailServer),
|
||||
NameServer(NameServer),
|
||||
Service(ServiceSingleTarget),
|
||||
Spf(Spf),
|
||||
TextData(TextData),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum FriendlyRDataAggregated {
|
||||
Addresses(Addresses),
|
||||
MailServers(MailServers),
|
||||
NameServers(NameServers),
|
||||
Service(Service),
|
||||
Spf(Spf),
|
||||
Texts(Texts),
|
||||
}
|
||||
|
||||
/* --------- RDATA --------- */
|
||||
|
||||
/* --------- Address --------- */
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Address {
|
||||
pub address: IpAddr,
|
||||
}
|
||||
|
||||
impl FromValue for Address {
|
||||
fn from_value(value: serde_json::Value) -> Result<Self, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let object = check_type!(value, Object, errors);
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(errors);
|
||||
}
|
||||
|
||||
let mut object = object.unwrap();
|
||||
|
||||
let address = get_object_value!(object, "address", errors);
|
||||
let address = if let Some(address) = address {
|
||||
append_errors!(FromValue::from_value(address), errors, "/address")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(Address {
|
||||
address: address.unwrap(),
|
||||
})
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Addresses {
|
||||
pub addresses: Vec<Address>,
|
||||
}
|
||||
|
||||
impl FromValue for Addresses {
|
||||
fn from_value(value: serde_json::Value) -> Result<Self, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let object = check_type!(value, Object, errors);
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(errors);
|
||||
}
|
||||
|
||||
let mut object = object.unwrap();
|
||||
|
||||
let addresses = get_object_value!(object, "addresses", errors);
|
||||
let addresses = if let Some(addresses) = addresses {
|
||||
append_errors!(FromValue::from_value(addresses), errors, "/addresses")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(Addresses {
|
||||
addresses: addresses.unwrap(),
|
||||
})
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
impl ToInternal for Addresses {
|
||||
fn internal(self, ttl: u32, node_name: Name) -> Vec<internal::Record> {
|
||||
self.addresses.into_iter().map(|addr| {
|
||||
internal::Record {
|
||||
ttl,
|
||||
name: node_name.clone(),
|
||||
rdata: match addr.address {
|
||||
IpAddr::V4(ip4) => internal::RData::A(internal::A::new(ip4)),
|
||||
IpAddr::V6(ip6) => internal::RData::Aaaa(internal::Aaaa::new(ip6)),
|
||||
}
|
||||
}
|
||||
}).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- Service --------- */
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ServiceSingleTarget {
|
||||
pub service_type: ServiceType,
|
||||
pub service_target: ServiceTarget,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Service {
|
||||
pub service_type: ServiceType,
|
||||
pub service_targets: Vec<ServiceTarget>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct ServiceTarget {
|
||||
pub port: i64,
|
||||
pub weight: i64,
|
||||
pub priority: i64,
|
||||
pub server: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Hash, Eq, PartialEq, Clone, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "lowercase", tag = "service_type")]
|
||||
pub enum ServiceType {
|
||||
Other { protocol: String, name: String },
|
||||
}
|
||||
|
||||
/* --------- MailServer --------- */
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct MailServer {
|
||||
pub preference: u16,
|
||||
pub mail_exchanger: Name,
|
||||
}
|
||||
|
||||
impl FromValue for MailServer {
|
||||
fn from_value(value: serde_json::Value) -> Result<Self, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let object = check_type!(value, Object, errors);
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(errors);
|
||||
}
|
||||
|
||||
let mut object = object.unwrap();
|
||||
|
||||
let preference = get_object_value!(object, "preference", errors);
|
||||
let preference = if let Some(preference) = preference {
|
||||
append_errors!(FromValue::from_value(preference), errors, "/preference")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mail_exchanger = get_object_value!(object, "mail_exchanger", errors);
|
||||
let mail_exchanger = if let Some(mail_exchanger) = mail_exchanger {
|
||||
append_errors!(FromValue::from_value(mail_exchanger), errors, "/mail_exchanger")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(MailServer {
|
||||
preference: preference.unwrap(),
|
||||
mail_exchanger: mail_exchanger.unwrap(),
|
||||
})
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct MailServers {
|
||||
pub mailservers: Vec<MailServer>
|
||||
}
|
||||
|
||||
impl FromValue for MailServers {
|
||||
fn from_value(value: serde_json::Value) -> Result<Self, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let object = check_type!(value, Object, errors);
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(errors);
|
||||
}
|
||||
|
||||
let mut object = object.unwrap();
|
||||
|
||||
let mailservers = get_object_value!(object, "mailservers", errors);
|
||||
let mailservers = if let Some(mailservers) = mailservers {
|
||||
append_errors!(FromValue::from_value(mailservers), errors, "/mailservers")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(MailServers {
|
||||
mailservers: mailservers.unwrap(),
|
||||
})
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
impl ToInternal for MailServers {
|
||||
fn internal(self, ttl: u32, node_name: Name) -> Vec<internal::Record> {
|
||||
self.mailservers.into_iter().map(|mailserver| {
|
||||
internal::Record {
|
||||
ttl,
|
||||
name: node_name.clone(),
|
||||
rdata: internal::RData::Mx(internal::Mx::new(mailserver.mail_exchanger, mailserver.preference)),
|
||||
}
|
||||
}).collect()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* --------- NameServer --------- */
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct NameServer {
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct NameServers {
|
||||
pub nameservers: Vec<NameServer>
|
||||
}
|
||||
|
||||
/* --------- TextData --------- */
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct TextData {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Texts {
|
||||
pub texts: Vec<TextData>,
|
||||
}
|
||||
|
||||
/* --------- Spf --------- */
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Spf {
|
||||
pub policy: Text
|
||||
}
|
||||
|
||||
impl FromValue for Spf {
|
||||
fn from_value(value: serde_json::Value) -> Result<Self, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let object = check_type!(value, Object, errors);
|
||||
|
||||
if !errors.is_empty() {
|
||||
return Err(errors);
|
||||
}
|
||||
|
||||
let mut object = object.unwrap();
|
||||
|
||||
let policy = get_object_value!(object, "policy", errors);
|
||||
let policy = if let Some(policy) = policy {
|
||||
append_errors!(FromValue::from_value(policy), errors, "/policy")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(Spf {
|
||||
policy: policy.unwrap(),
|
||||
})
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
impl ToInternal for Spf {
|
||||
fn internal(self, ttl: u32, node_name: Name) -> Vec<internal::Record> {
|
||||
vec![
|
||||
internal::Record {
|
||||
ttl,
|
||||
name: node_name,
|
||||
rdata: internal::RData::Txt(internal::Txt::new(self.policy))
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- Alias --------- */
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Alias {
|
||||
pub from: String,
|
||||
pub target: String,
|
||||
pub ttl: i64,
|
||||
}
|
249
src/resources/dns/friendly/record.rs
Normal file
249
src/resources/dns/friendly/record.rs
Normal file
|
@ -0,0 +1,249 @@
|
|||
use std::collections::HashMap;
|
||||
use std::hash::Hash;
|
||||
|
||||
use serde::{Serialize, Deserialize, Serializer};
|
||||
|
||||
use crate::resources::dns::internal;
|
||||
|
||||
use super::rdata::{
|
||||
FriendlyRDataAggregated,
|
||||
FriendlyRData,
|
||||
ServiceType,
|
||||
self
|
||||
};
|
||||
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all="lowercase")]
|
||||
pub enum RecordSection {
|
||||
Mail,
|
||||
Web,
|
||||
Services,
|
||||
Miscellaneous,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize, Hash, Eq, PartialEq)]
|
||||
#[serde(rename_all="lowercase")]
|
||||
pub enum FriendlyRType {
|
||||
Addresses,
|
||||
Alias,
|
||||
MailServers,
|
||||
NameServers,
|
||||
Service,
|
||||
Spf,
|
||||
Texts,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FriendlyRecord {
|
||||
ttl: u32,
|
||||
data: FriendlyRDataAggregated,
|
||||
}
|
||||
|
||||
impl Serialize for FriendlyRecord {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
#[derive(Serialize)]
|
||||
struct ExtendedRecord<'a> {
|
||||
ttl: u32,
|
||||
record_type: FriendlyRType,
|
||||
record_section: RecordSection,
|
||||
#[serde(flatten)]
|
||||
data: &'a FriendlyRDataAggregated,
|
||||
}
|
||||
|
||||
let extended_record = ExtendedRecord {
|
||||
ttl: self.ttl,
|
||||
data: &self.data,
|
||||
record_type: self.record_type(),
|
||||
record_section: self.record_section(),
|
||||
};
|
||||
|
||||
extended_record.serialize(serializer)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
impl FriendlyRecord {
|
||||
pub fn new(ttl: u32, data: FriendlyRDataAggregated) -> Self {
|
||||
FriendlyRecord {
|
||||
ttl,
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_type(&self) -> FriendlyRType {
|
||||
match self.data {
|
||||
FriendlyRDataAggregated::Addresses(_) => FriendlyRType::Addresses,
|
||||
FriendlyRDataAggregated::MailServers(_) => FriendlyRType::MailServers,
|
||||
FriendlyRDataAggregated::NameServers(_) => FriendlyRType::NameServers,
|
||||
FriendlyRDataAggregated::Service(_) => FriendlyRType::Service,
|
||||
FriendlyRDataAggregated::Spf(_) => FriendlyRType::Spf,
|
||||
FriendlyRDataAggregated::Texts(_) => FriendlyRType::Texts,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_section(&self) -> RecordSection {
|
||||
match self.data {
|
||||
FriendlyRDataAggregated::Addresses(_) => RecordSection::Web,
|
||||
FriendlyRDataAggregated::MailServers(_) => RecordSection::Mail,
|
||||
FriendlyRDataAggregated::NameServers(_) => RecordSection::Miscellaneous,
|
||||
FriendlyRDataAggregated::Service(_) => RecordSection::Services,
|
||||
FriendlyRDataAggregated::Spf(_) => RecordSection::Mail,
|
||||
FriendlyRDataAggregated::Texts(_) => RecordSection::Miscellaneous,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Node {
|
||||
pub name: String,
|
||||
pub records: Vec<FriendlyRecord>,
|
||||
}
|
||||
|
||||
impl Node {
|
||||
fn new(name: String) -> Self {
|
||||
Node {
|
||||
name,
|
||||
records: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct FriendlyRecords {
|
||||
records: Vec<Node>,
|
||||
aliases: Vec<rdata::Alias>,
|
||||
}
|
||||
|
||||
impl FriendlyRecords {
|
||||
pub fn new() -> Self {
|
||||
FriendlyRecords {
|
||||
records: Vec::new(),
|
||||
aliases: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<internal::RecordList> for FriendlyRecords {
|
||||
fn from(value: internal::RecordList) -> Self {
|
||||
|
||||
let mut records = FriendlyRecords::new();
|
||||
let mut name_mapping: HashMap<String, HashMap<FriendlyRType, FriendlyRecord>> = HashMap::new();
|
||||
let mut service_mapping: HashMap<(String, ServiceType), FriendlyRecord> = HashMap::new();
|
||||
|
||||
for record in value.records {
|
||||
let internal::Record { name, ttl, rdata } = record;
|
||||
let name = name.to_string();
|
||||
let rdata = rdata.friendly(&name, ttl);
|
||||
|
||||
if rdata.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (name, rdata) = rdata.unwrap();
|
||||
|
||||
if let FriendlyRData::Alias(alias) = rdata {
|
||||
records.aliases.push(alias)
|
||||
} else {
|
||||
let node = name_mapping.entry(name.clone()).or_default();
|
||||
|
||||
match rdata {
|
||||
FriendlyRData::Address(address) => {
|
||||
let addresses = node.entry(FriendlyRType::Addresses).or_insert_with(|| {
|
||||
FriendlyRecord::new(ttl, FriendlyRDataAggregated::Addresses(rdata::Addresses {
|
||||
addresses: Vec::new()
|
||||
}))
|
||||
});
|
||||
|
||||
match addresses.data {
|
||||
FriendlyRDataAggregated::Addresses(ref mut addresses) => addresses.addresses.push(address),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
},
|
||||
FriendlyRData::MailServer(mailserver) => {
|
||||
let mailservers = node.entry(FriendlyRType::MailServers).or_insert_with(|| {
|
||||
FriendlyRecord::new(ttl, FriendlyRDataAggregated::MailServers(rdata::MailServers {
|
||||
mailservers: Vec::new()
|
||||
}))
|
||||
});
|
||||
|
||||
match mailservers.data {
|
||||
FriendlyRDataAggregated::MailServers(ref mut mailservers) => mailservers.mailservers.push(mailserver),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
},
|
||||
FriendlyRData::Spf(spf) => {
|
||||
node.insert(FriendlyRType::Spf, FriendlyRecord::new(ttl, FriendlyRDataAggregated::Spf(spf)));
|
||||
},
|
||||
FriendlyRData::Service(service_single) => {
|
||||
let service = service_mapping.entry((name.clone(), service_single.service_type.clone()))
|
||||
.or_insert_with(|| {
|
||||
FriendlyRecord::new(ttl, FriendlyRDataAggregated::Service(rdata::Service {
|
||||
service_type: service_single.service_type,
|
||||
service_targets: Vec::new(),
|
||||
|
||||
}))
|
||||
});
|
||||
|
||||
match service.data {
|
||||
FriendlyRDataAggregated::Service(ref mut service) => service.service_targets.push(service_single.service_target),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
},
|
||||
FriendlyRData::NameServer(nameserver) => {
|
||||
// TODO: NS -> Skip if NS for zone (authority), create Delegation section with glue + DS for others (how to check if record is glue?)
|
||||
let nameservers = node.entry(FriendlyRType::NameServers).or_insert_with(|| {
|
||||
FriendlyRecord::new(ttl, FriendlyRDataAggregated::NameServers(rdata::NameServers {
|
||||
nameservers: Vec::new()
|
||||
}))
|
||||
});
|
||||
|
||||
match nameservers.data {
|
||||
FriendlyRDataAggregated::NameServers(ref mut nameservers) => nameservers.nameservers.push(nameserver),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
},
|
||||
FriendlyRData::TextData(text) => {
|
||||
let texts = node.entry(FriendlyRType::Texts).or_insert_with(|| {
|
||||
FriendlyRecord::new(ttl, FriendlyRDataAggregated::Texts(rdata::Texts {
|
||||
texts: Vec::new()
|
||||
}))
|
||||
});
|
||||
|
||||
match texts.data {
|
||||
FriendlyRDataAggregated::Texts(ref mut texts) => texts.texts.push(text),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
},
|
||||
FriendlyRData::Alias(_) => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut nodes: HashMap<String, Node> = HashMap::new();
|
||||
|
||||
for ((name, _), service) in service_mapping {
|
||||
let node = nodes.entry(name.clone())
|
||||
.or_insert_with(|| Node::new(name));
|
||||
node.records.push(service);
|
||||
}
|
||||
|
||||
for (name, node_records) in name_mapping {
|
||||
let node = nodes.entry(name.clone())
|
||||
.or_insert_with(|| Node::new(name));
|
||||
for (_, record) in node_records {
|
||||
node.records.push(record);
|
||||
}
|
||||
}
|
||||
|
||||
records.records = nodes.into_values().collect();
|
||||
|
||||
records.records.sort_by_key(|node| node.name.clone());
|
||||
|
||||
records
|
||||
}
|
||||
}
|
140
src/resources/dns/internal/base.rs
Normal file
140
src/resources/dns/internal/base.rs
Normal file
|
@ -0,0 +1,140 @@
|
|||
use std::fmt;
|
||||
use domain::base::scan::Symbol;
|
||||
use serde::{Serialize, Deserialize, Serializer};
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Deserialize)]
|
||||
pub struct Name {
|
||||
name: String
|
||||
}
|
||||
|
||||
impl Name {
|
||||
pub fn new(name: String) -> Self {
|
||||
Name {
|
||||
name,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ends_with(&self, other: &Name) -> bool {
|
||||
self.name == other.name || self.name.ends_with(&(String::from(".") + &other.name))
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Name {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Name {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Name {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
let mut labels = self.name.split('.').rev();
|
||||
let mut other_labels = other.name.split('.').rev();
|
||||
|
||||
loop {
|
||||
match (labels.next(), other_labels.next()) {
|
||||
(Some(label), Some(other_label)) => match label.cmp(other_label) {
|
||||
std::cmp::Ordering::Equal => (),
|
||||
res => return res,
|
||||
},
|
||||
(None, Some(_)) => return std::cmp::Ordering::Less,
|
||||
(Some(_), None) => return std::cmp::Ordering::Greater,
|
||||
(None, None) => return std::cmp::Ordering::Equal,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Name {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Eq, PartialEq)]
|
||||
pub enum Rtype {
|
||||
A,
|
||||
Aaaa,
|
||||
Cname,
|
||||
Mx,
|
||||
Ns,
|
||||
Ptr,
|
||||
Soa,
|
||||
Srv,
|
||||
Txt
|
||||
}
|
||||
|
||||
impl Rtype {
|
||||
pub fn value(&self) -> u16 {
|
||||
match self {
|
||||
Rtype::A => 1,
|
||||
Rtype::Aaaa => 28,
|
||||
Rtype::Cname => 5,
|
||||
Rtype::Mx => 15,
|
||||
Rtype::Ns => 2,
|
||||
Rtype::Ptr => 12,
|
||||
Rtype::Soa => 6,
|
||||
Rtype::Srv => 33,
|
||||
Rtype::Txt => 16,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Rtype {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
self.value().cmp(&other.value())
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for Rtype {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct Text {
|
||||
pub data: Vec<u8>
|
||||
}
|
||||
|
||||
impl Text {
|
||||
pub fn new(data: Vec<u8>) -> Self {
|
||||
Text {
|
||||
data,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bytes(self) -> Vec<u8> {
|
||||
self.data
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for Text {
|
||||
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: Serializer,
|
||||
{
|
||||
serializer.serialize_str(&self.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Text {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
|
||||
for c in &self.data {
|
||||
// Escapes '\' and non printable chars
|
||||
let c = Symbol::display_from_octet(*c);
|
||||
write!(f, "{}", c)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
7
src/resources/dns/internal/mod.rs
Normal file
7
src/resources/dns/internal/mod.rs
Normal file
|
@ -0,0 +1,7 @@
|
|||
pub mod rdata;
|
||||
pub mod record;
|
||||
pub mod base;
|
||||
|
||||
pub use rdata::*;
|
||||
pub use record::*;
|
||||
pub use base::*;
|
200
src/resources/dns/internal/rdata.rs
Normal file
200
src/resources/dns/internal/rdata.rs
Normal file
|
@ -0,0 +1,200 @@
|
|||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use super::{Name, Rtype, Text};
|
||||
|
||||
use crate::resources::dns::friendly;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum RData {
|
||||
A(A),
|
||||
Aaaa(Aaaa),
|
||||
Cname(Cname),
|
||||
Mx(Mx),
|
||||
Ns(Ns),
|
||||
Ptr(Ptr),
|
||||
Soa(Soa),
|
||||
Srv(Srv),
|
||||
Txt(Txt),
|
||||
}
|
||||
|
||||
impl RData {
|
||||
pub fn rtype(&self) -> Rtype {
|
||||
match self {
|
||||
RData::A(_) => Rtype::A,
|
||||
RData::Aaaa(_) => Rtype::Aaaa,
|
||||
RData::Cname(_) => Rtype::Cname,
|
||||
RData::Mx(_) => Rtype::Mx,
|
||||
RData::Ns(_) => Rtype::Ns,
|
||||
RData::Ptr(_) => Rtype::Ptr,
|
||||
RData::Soa(_) => Rtype::Soa,
|
||||
RData::Srv(_) => Rtype::Srv,
|
||||
RData::Txt(_) => Rtype::Txt,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn friendly(self, owner: &str, ttl: u32) -> Option<(String, friendly::FriendlyRData)> {
|
||||
match self {
|
||||
RData::A(data) => data.friendly(owner),
|
||||
RData::Aaaa(data) => data.friendly(owner),
|
||||
RData::Cname(data) => data.friendly(owner, ttl),
|
||||
RData::Mx(data) => data.friendly(owner),
|
||||
RData::Ns(data) => data.friendly(owner),
|
||||
RData::Srv(data) => data.friendly(owner),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct A {
|
||||
pub address: Ipv4Addr,
|
||||
}
|
||||
|
||||
impl A {
|
||||
pub fn new(address: Ipv4Addr) -> Self {
|
||||
A {
|
||||
address
|
||||
}
|
||||
}
|
||||
|
||||
pub fn friendly(self, owner: &str) -> Option<(String, friendly::FriendlyRData)> {
|
||||
Some((owner.to_string(), friendly::FriendlyRData::Address(friendly::Address {
|
||||
address: self.address.into()
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Aaaa {
|
||||
pub address: Ipv6Addr,
|
||||
}
|
||||
|
||||
impl Aaaa {
|
||||
|
||||
pub fn new(address: Ipv6Addr) -> Self {
|
||||
Aaaa {
|
||||
address
|
||||
}
|
||||
}
|
||||
pub fn friendly(self, owner: &str) -> Option<(String, friendly::FriendlyRData)> {
|
||||
Some((owner.to_string(), friendly::FriendlyRData::Address(friendly::Address {
|
||||
address: self.address.into()
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Cname {
|
||||
pub target: Name,
|
||||
}
|
||||
|
||||
impl Cname {
|
||||
pub fn friendly(self, owner: &str, ttl: u32) -> Option<(String, friendly::FriendlyRData)> {
|
||||
Some((owner.to_string(), friendly::FriendlyRData::Alias(friendly::Alias {
|
||||
from: owner.into(),
|
||||
target: self.target.to_string(),
|
||||
ttl: ttl.into(),
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Mx {
|
||||
pub preference: u16,
|
||||
pub mail_exchanger: Name,
|
||||
}
|
||||
|
||||
impl Mx {
|
||||
pub fn new(mail_exchanger: Name, preference: u16) -> Self {
|
||||
Mx {
|
||||
mail_exchanger,
|
||||
preference,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn friendly(self, owner: &str) -> Option<(String, friendly::FriendlyRData)> {
|
||||
Some((owner.to_string(), friendly::FriendlyRData::MailServer(friendly::MailServer {
|
||||
preference: self.preference,
|
||||
mail_exchanger: self.mail_exchanger,
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Ns {
|
||||
pub target: Name,
|
||||
}
|
||||
|
||||
impl Ns {
|
||||
pub fn friendly(self, owner: &str) -> Option<(String, friendly::FriendlyRData)> {
|
||||
Some((owner.to_string(), friendly::FriendlyRData::NameServer(friendly::NameServer {
|
||||
target: self.target.to_string(),
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Ptr {
|
||||
pub target: Name,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Soa {
|
||||
pub primary_server: Name,
|
||||
pub maintainer: Name,
|
||||
pub refresh: u32,
|
||||
pub retry: u32,
|
||||
pub expire: u32,
|
||||
pub minimum: u32,
|
||||
pub serial: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Srv {
|
||||
pub server: Name,
|
||||
pub port: u16,
|
||||
pub priority: u16,
|
||||
pub weight: u16,
|
||||
}
|
||||
|
||||
impl Srv {
|
||||
pub fn friendly(self, owner: &str) -> Option<(String, friendly::FriendlyRData)> {
|
||||
let labels: Vec<_> = owner.splitn(3, '.').collect();
|
||||
if labels.len() != 3 {
|
||||
None
|
||||
} else {
|
||||
let service_name = labels[0]. strip_prefix('_');
|
||||
let protocol = labels[1]. strip_prefix('_');
|
||||
if let (Some(service_name), Some(protocol)) = (service_name, protocol) {
|
||||
Some((labels[2].to_string(), friendly::FriendlyRData::Service(friendly::ServiceSingleTarget {
|
||||
service_type: friendly::ServiceType::Other {
|
||||
name: service_name.into(),
|
||||
protocol: protocol.into()
|
||||
},
|
||||
service_target: friendly::ServiceTarget {
|
||||
port: self.port.into(),
|
||||
weight: self.weight.into(),
|
||||
priority: self.priority.into(),
|
||||
server: self.server.to_string()
|
||||
}
|
||||
})))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Txt {
|
||||
pub text: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Txt {
|
||||
pub fn new(text: Text) -> Self {
|
||||
Txt {
|
||||
text: text.bytes()
|
||||
}
|
||||
}
|
||||
}
|
30
src/resources/dns/internal/record.rs
Normal file
30
src/resources/dns/internal/record.rs
Normal file
|
@ -0,0 +1,30 @@
|
|||
use super::rdata::RData;
|
||||
use super::Name;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Record {
|
||||
pub name: Name,
|
||||
pub ttl: u32,
|
||||
pub rdata: RData
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RecordList {
|
||||
pub records: Vec<Record>,
|
||||
}
|
||||
|
||||
impl RecordList {
|
||||
pub fn new(records: &[Record]) -> Self {
|
||||
RecordList { records: records.into() }
|
||||
}
|
||||
pub fn sort(&mut self) {
|
||||
self.records.sort_by(|r1, r2| {
|
||||
let key1 = (&r1.name, r1.rdata.rtype());
|
||||
let key2 = (&r2.name, r2.rdata.rtype());
|
||||
key1.cmp(&key2)
|
||||
});
|
||||
}
|
||||
}
|
||||
pub struct AddRecords {
|
||||
pub new_records: RecordList
|
||||
}
|
3
src/resources/dns/mod.rs
Normal file
3
src/resources/dns/mod.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
pub mod external;
|
||||
pub mod internal;
|
||||
pub mod friendly;
|
|
@ -1,3 +1,4 @@
|
|||
/*
|
||||
pub mod class;
|
||||
pub mod errors;
|
||||
pub mod name;
|
||||
|
@ -16,3 +17,7 @@ pub use user::{LocalUser, UserInfo, Role, UserZone, User, CreateUserRequest};
|
|||
pub use rdata::RData;
|
||||
pub use record::{Record, RecordList, ParseRecordList, RecordListParseError, UpdateRecordsRequest};
|
||||
pub use zone::{Zone, AddZoneMemberRequest, CreateZoneRequest};
|
||||
*/
|
||||
|
||||
pub mod zone;
|
||||
pub mod dns;
|
|
@ -1,3 +1,4 @@
|
|||
/*
|
||||
use uuid::Uuid;
|
||||
use diesel::prelude::*;
|
||||
use diesel::result::Error as DieselError;
|
||||
|
@ -234,3 +235,4 @@ impl LocalUser {
|
|||
})
|
||||
}
|
||||
}
|
||||
*/
|
239
src/resources/zone.rs
Normal file
239
src/resources/zone.rs
Normal file
|
@ -0,0 +1,239 @@
|
|||
use async_trait::async_trait;
|
||||
use axum::response::Response;
|
||||
use domain::base::record;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use rusqlite::Error as RusqliteError;
|
||||
|
||||
|
||||
use crate::database::{BoxedDb, sqlite::SqliteDB};
|
||||
use crate::dns::{BoxedZoneDriver, BoxedRecordDriver, DnsDriverError};
|
||||
use crate::errors::Error;
|
||||
use crate::macros::push_error;
|
||||
use crate::resources::dns::internal::{self, RecordList};
|
||||
use crate::validation;
|
||||
|
||||
pub enum ZoneError {
|
||||
ZoneConflict { name: String },
|
||||
NotFound { name: String },
|
||||
NotExistsNs { name: String },
|
||||
Validation { suberrors: Vec<Error> },
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Zone {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl Zone {
|
||||
pub async fn create(create_zone: CreateZoneRequest, zone_driver: BoxedZoneDriver, db: BoxedDb) -> Result<Self, Error> {
|
||||
let create_zone = create_zone.validate()?;
|
||||
|
||||
zone_driver.zone_exists(&create_zone.name)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
match e {
|
||||
DnsDriverError::ZoneNotFound { name } => {
|
||||
Error::from(ZoneError::NotExistsNs { name })
|
||||
.with_path("/name")
|
||||
},
|
||||
e => Error::from(e)
|
||||
}
|
||||
})?;
|
||||
|
||||
db.create_zone(create_zone).await
|
||||
}
|
||||
|
||||
pub async fn get_records(&self, record_driver: BoxedRecordDriver) ->Result<RecordList, Error> {
|
||||
let mut records = record_driver.get_records(&self.name).await?;
|
||||
|
||||
records.sort();
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
pub async fn add_records(&self, record_driver: BoxedRecordDriver, records: internal::RecordList) -> Result<(), Error> {
|
||||
record_driver.add_records(&self.name, records).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateZoneRequest {
|
||||
pub name: String
|
||||
}
|
||||
|
||||
pub struct CreateZone {
|
||||
pub name: String
|
||||
}
|
||||
|
||||
impl CreateZoneRequest {
|
||||
pub fn validate(self) -> Result<CreateZone, Error> {
|
||||
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let name = push_error!(validation::normalize_domain(&self.name), errors, "/name");
|
||||
name.ok_or(Error::from(ZoneError::Validation { suberrors: errors }))
|
||||
.map(|name| {
|
||||
CreateZone { name }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ZoneModel: Send + Sync {
|
||||
async fn create_zone(&self, create_zone: CreateZone) -> Result<Zone, Error>;
|
||||
async fn get_zone_by_name(&self, zone_name: &str) -> Result<Zone, Error>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ZoneModel for SqliteDB {
|
||||
async fn create_zone(&self, create_zone: CreateZone) -> Result<Zone, Error> {
|
||||
let pool = self.pool.clone();
|
||||
|
||||
let conn = pool.get().await?;
|
||||
|
||||
tokio::task::block_in_place(move || {
|
||||
let mut stmt = conn.prepare("insert into zones (name) values (?1) returning *")?;
|
||||
let zone = stmt.query_row((&create_zone.name,), |row| {
|
||||
Ok(Zone {
|
||||
name: row.get(0)?
|
||||
})
|
||||
}).map_err(|e| {
|
||||
match e {
|
||||
/* SQLITE_CONSTRAINT_PRIMARYKEY */
|
||||
RusqliteError::SqliteFailure(e, _) if e.extended_code == 1555 => {
|
||||
Error::from(ZoneError::ZoneConflict { name: create_zone.name })
|
||||
.with_path("/name")
|
||||
},
|
||||
e => Error::new("internal:zone:create", "Failed to create zone")
|
||||
.with_cause(&e.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(zone)
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_zone_by_name(&self, zone_name: &str) -> Result<Zone, Error> {
|
||||
let pool = self.pool.clone();
|
||||
|
||||
let conn = pool.get().await?;
|
||||
|
||||
tokio::task::block_in_place(move || {
|
||||
let mut stmt = conn.prepare("select * from zones where name = ?1")?;
|
||||
let zone = stmt.query_row((zone_name,), |row| {
|
||||
Ok(Zone {
|
||||
name: row.get(0)?
|
||||
})
|
||||
}).map_err(|e| {
|
||||
match e {
|
||||
RusqliteError::QueryReturnedNoRows => {
|
||||
Error::from(ZoneError::NotFound { name: zone_name.to_string() })
|
||||
},
|
||||
e => Error::new("internal:zone:get_by_name", "Failed to fetch zone by name")
|
||||
.with_cause(&e.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(zone)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
use crate::models::user::UserInfo;
|
||||
|
||||
use uuid::Uuid;
|
||||
use diesel::prelude::*;
|
||||
use diesel::result::Error as DieselError;
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
use crate::schema::*;
|
||||
use super::name::AbsoluteName;
|
||||
use super::user::UserZone;
|
||||
use super::errors::UserError;
|
||||
|
||||
|
||||
#[derive(Debug, Serialize, Queryable, Identifiable, Insertable)]
|
||||
#[table_name = "zone"]
|
||||
pub struct Zone {
|
||||
#[serde(skip)]
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AddZoneMemberRequest {
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, FromForm)]
|
||||
pub struct CreateZoneRequest {
|
||||
pub name: AbsoluteName,
|
||||
}
|
||||
|
||||
// NOTE: Should probably not be implemented here
|
||||
// also, "UserError" seems like a misleading name
|
||||
impl Zone {
|
||||
pub fn get_all(conn: &diesel::SqliteConnection) -> Result<Vec<Zone>, UserError> {
|
||||
use crate::schema::zone::dsl::*;
|
||||
|
||||
zone.get_results(conn)
|
||||
.map_err(UserError::DbError)
|
||||
}
|
||||
|
||||
pub fn get_by_name(conn: &diesel::SqliteConnection, zone_name: &str) -> Result<Zone, UserError> {
|
||||
use crate::schema::zone::dsl::*;
|
||||
|
||||
zone.filter(name.eq(zone_name))
|
||||
.get_result(conn)
|
||||
.map_err(|e| match e {
|
||||
DieselError::NotFound => UserError::ZoneNotFound,
|
||||
other => UserError::DbError(other)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_zone(conn: &diesel::SqliteConnection, zone_request: CreateZoneRequest) -> Result<Zone, UserError> {
|
||||
use crate::schema::zone::dsl::*;
|
||||
|
||||
let new_zone = Zone {
|
||||
id: Uuid::new_v4().to_simple().to_string(),
|
||||
name: zone_request.name.to_utf8(),
|
||||
};
|
||||
|
||||
diesel::insert_into(zone)
|
||||
.values(&new_zone)
|
||||
.execute(conn)
|
||||
.map_err(|e| match e {
|
||||
DieselError::DatabaseError(diesel::result::DatabaseErrorKind::UniqueViolation, _) => UserError::UserConflict,
|
||||
other => UserError::DbError(other)
|
||||
})?;
|
||||
Ok(new_zone)
|
||||
}
|
||||
|
||||
|
||||
pub fn add_member(&self, conn: &diesel::SqliteConnection, new_member: &UserInfo) -> Result<(), UserError> {
|
||||
use crate::schema::user_zone::dsl::*;
|
||||
|
||||
let new_user_zone = UserZone {
|
||||
zone_id: self.id.clone(),
|
||||
user_id: new_member.id.clone()
|
||||
};
|
||||
|
||||
let res = diesel::insert_into(user_zone)
|
||||
.values(new_user_zone)
|
||||
.execute(conn);
|
||||
|
||||
match res {
|
||||
// If user has already access to the zone, safely ignore the conflit
|
||||
// TODO: use 'on conflict do nothing' in postgres when we get there
|
||||
Err(DieselError::DatabaseError(diesel::result::DatabaseErrorKind::UniqueViolation, _)) => (),
|
||||
Err(e) => return Err(e.into()),
|
||||
Ok(_) => ()
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
*/
|
|
@ -1,5 +1,5 @@
|
|||
pub mod users;
|
||||
//pub mod users;
|
||||
pub mod zones;
|
||||
|
||||
pub use users::*;
|
||||
pub use zones::*;
|
||||
//pub use users::*;
|
||||
//pub use zones::*;
|
||||
|
|
|
@ -1,3 +1,48 @@
|
|||
use axum::extract::{Path, State};
|
||||
use axum::Json;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::errors::Error;
|
||||
use crate::resources::zone::{CreateZoneRequest, Zone};
|
||||
use crate::resources::dns::external;
|
||||
use crate::resources::dns::internal;
|
||||
|
||||
|
||||
pub async fn create_zone(
|
||||
State(app): State<AppState>,
|
||||
Json(create_zone): Json<CreateZoneRequest>,
|
||||
) -> Result<Json<Zone>, Error>
|
||||
{
|
||||
Zone::create(create_zone, app.zone, app.db).await.map(Json)
|
||||
}
|
||||
|
||||
pub async fn get_zone_records(
|
||||
Path(zone_name): Path<String>,
|
||||
State(app): State<AppState>,
|
||||
) -> Result<Json<external::RecordList>, Error>
|
||||
{
|
||||
let zone = app.db.get_zone_by_name(&zone_name).await?;
|
||||
zone.get_records(app.records)
|
||||
.await
|
||||
.map(|records| Json(records.into()))
|
||||
}
|
||||
|
||||
pub async fn create_zone_records(
|
||||
Path(zone_name): Path<String>,
|
||||
State(app): State<AppState>,
|
||||
Json(add_records): Json<external::AddRecords>,
|
||||
) -> Result<Json<external::RecordList>, Error>
|
||||
{
|
||||
|
||||
let zone = app.db.get_zone_by_name(&zone_name).await?;
|
||||
let name = internal::Name::new(zone.name.clone());
|
||||
let add_records = add_records.validate(&name)?;
|
||||
app.records.add_records(&zone.name, add_records.new_records.clone()).await?;
|
||||
|
||||
Ok(Json(add_records.new_records.into()))
|
||||
}
|
||||
|
||||
/*
|
||||
use rocket::http::Status;
|
||||
|
||||
use rocket::serde::json::Json;
|
||||
|
@ -178,3 +223,4 @@ pub async fn add_member_to_zone<'r>(
|
|||
|
||||
Ok(Status::Created) // TODO: change this?
|
||||
}
|
||||
*/
|
||||
|
|
|
@ -1,5 +1,2 @@
|
|||
pub mod auth;
|
||||
//pub mod auth;
|
||||
pub mod zones;
|
||||
|
||||
pub use auth::*;
|
||||
pub use zones::*;
|
||||
|
|
|
@ -1,128 +1,121 @@
|
|||
use axum::extract::{Query, Path, State, OriginalUri};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Extension;
|
||||
use serde_json::{Value, json};
|
||||
use serde::Serialize;
|
||||
use rocket::http::{Status};
|
||||
use rocket::http::uri::Origin;
|
||||
use rocket::form::Form;
|
||||
use unic_langid::LanguageIdentifier;
|
||||
|
||||
use crate::form::Node;
|
||||
use crate::macros::append_errors;
|
||||
use crate::AppState;
|
||||
use crate::errors::{Error, error_map};
|
||||
use crate::template::Template;
|
||||
use crate::models;
|
||||
use crate::controllers;
|
||||
use crate::DbConn;
|
||||
use crate::dns::ZoneConnector;
|
||||
use crate::resources::dns::friendly::{self, NewRecordQuery};
|
||||
use crate::resources::dns::internal;
|
||||
|
||||
pub async fn get_records_page(
|
||||
Path(zone_name): Path<String>,
|
||||
State(app): State<AppState>,
|
||||
OriginalUri(url): OriginalUri,
|
||||
Extension(lang): Extension<LanguageIdentifier>,
|
||||
) -> Result<Template<'static, Value>, Error> {
|
||||
let zone = app.db.get_zone_by_name(&zone_name).await?;
|
||||
let records = zone.get_records(app.records).await?;
|
||||
let records = friendly::FriendlyRecords::from(records.clone());
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct RecordsPage {
|
||||
zone: String
|
||||
Ok(Template::new(
|
||||
"pages/records.html",
|
||||
app.template_engine,
|
||||
json!({
|
||||
"current_zone": zone.name,
|
||||
"records": records,
|
||||
"url": url.to_string(),
|
||||
"lang": lang.to_string(),
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
// TODO: Check if origin changes if application mounted on different path
|
||||
#[get("/zone/<zone>/records")]
|
||||
pub async fn get_zone_records_page(user_info: models::UserInfo, zone: models::AbsoluteName, conn: DbConn, origin: &Origin<'_>) -> Result<Template<'static, Value>, Status> {
|
||||
let zone_name = zone.to_utf8();
|
||||
pub async fn get_new_record_page(
|
||||
Path(zone_name): Path<String>,
|
||||
State(app): State<AppState>,
|
||||
Query(mut params): Query<NewRecordQuery>,
|
||||
OriginalUri(url): OriginalUri,
|
||||
Extension(lang): Extension<LanguageIdentifier>,
|
||||
) -> Result<Template<'static, Value>, Error> {
|
||||
let zone = app.db.get_zone_by_name(&zone_name).await?;
|
||||
|
||||
let zones = conn.run(move |c| {
|
||||
if user_info.is_admin() {
|
||||
models::Zone::get_by_name(c, &zone_name)?;
|
||||
models::Zone::get_all(c)
|
||||
let mut errors = Vec::new();
|
||||
append_errors!(params.validate(&zone.name), errors);
|
||||
|
||||
Ok(Template::new(
|
||||
"pages/new_record.html",
|
||||
app.template_engine,
|
||||
json!({
|
||||
"current_zone": zone.name,
|
||||
"new_record_name": params.name,
|
||||
"errors": error_map(errors),
|
||||
"config": params.config,
|
||||
"rtype": params.rtype,
|
||||
"url": url.to_string(),
|
||||
"lang": lang.to_string(),
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn post_new_record(
|
||||
Path(zone_name): Path<String>,
|
||||
State(app): State<AppState>,
|
||||
Query(mut params): Query<NewRecordQuery>,
|
||||
OriginalUri(url): OriginalUri,
|
||||
Extension(lang): Extension<LanguageIdentifier>,
|
||||
form: Node,
|
||||
) -> Result<Response, Error> {
|
||||
let zone = app.db.get_zone_by_name(&zone_name).await?;
|
||||
let mut errors = Vec::new();
|
||||
append_errors!(params.validate(&zone.name), errors);
|
||||
|
||||
if !errors.is_empty() || params.name.is_none() || !(params.config.is_none() ^ params.config.is_some()) {
|
||||
return Ok(StatusCode::NOT_FOUND.into_response());
|
||||
}
|
||||
|
||||
let name = params.name.clone().map(internal::Name::new).unwrap();
|
||||
let input_data = form.into_json_value();
|
||||
|
||||
let new_records = {
|
||||
let new_records = if let Some(config_type) = params.config.clone() {
|
||||
config_type.get_records(input_data.clone(), 3600, name)
|
||||
} else if let Some(_rtype) = params.rtype {
|
||||
unimplemented!()
|
||||
} else {
|
||||
user_info.get_zone(c, &zone_name)?;
|
||||
user_info.get_zones(c)
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
}
|
||||
}).await.map_err(|e| models::ErrorResponse::from(e).status)?;
|
||||
append_errors!(new_records, errors)
|
||||
};
|
||||
|
||||
if let Some(new_records) = new_records {
|
||||
zone.add_records(
|
||||
app.records,
|
||||
internal::RecordList::new(&new_records)
|
||||
).await?;
|
||||
|
||||
Ok(().into_response())
|
||||
} else {
|
||||
Ok(Template::new(
|
||||
"pages/new_record.html",
|
||||
app.template_engine,
|
||||
json!({
|
||||
"current_zone": zone.name,
|
||||
"new_record_name": params.name,
|
||||
"input_data": input_data,
|
||||
"errors": error_map(errors),
|
||||
"config": params.config,
|
||||
"rtype": params.rtype,
|
||||
"url": url.to_string(),
|
||||
"lang": lang.to_string(),
|
||||
})
|
||||
).into_response())
|
||||
}
|
||||
|
||||
Ok(Template::new(
|
||||
"pages/zone/records.html",
|
||||
json!({
|
||||
"current_zone": zone.to_utf8(),
|
||||
"zones": zones,
|
||||
"nav_page": origin.clone().into_normalized().path().as_str(),
|
||||
"nav_sections": vec!["zones", zone.to_utf8().as_str()],
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
#[get("/zones")]
|
||||
pub async fn get_zones_page(user_info: models::UserInfo, conn: DbConn, origin: &Origin<'_>) -> Result<Template<'static, Value>, Status> {
|
||||
let zones = controllers::get_zones(
|
||||
&conn,
|
||||
user_info
|
||||
).await.map_err(|e| e.status)?;
|
||||
|
||||
Ok(Template::new(
|
||||
"pages/zones.html",
|
||||
json!({
|
||||
"zones": zones,
|
||||
"nav_page": origin.clone().into_normalized().path().as_str(),
|
||||
"nav_sections": vec!["zones"],
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
#[get("/zones/new")]
|
||||
pub async fn get_create_zone_page(
|
||||
conn: DbConn,
|
||||
user_info: models::UserInfo,
|
||||
origin: &Origin<'_>
|
||||
) -> Result<Template<'static, Value>, Status> {
|
||||
|
||||
user_info
|
||||
.check_admin()
|
||||
.map_err(|e| models::ErrorResponse::from(e).status)?;
|
||||
|
||||
let zones = controllers::get_zones(
|
||||
&conn,
|
||||
user_info
|
||||
).await.map_err(|e| e.status)?;
|
||||
|
||||
Ok(Template::new(
|
||||
"pages/zones/new.html",
|
||||
json!({
|
||||
"zone": None::<models::Zone>,
|
||||
"zones": zones,
|
||||
"error": None::<String>,
|
||||
"nav_page": origin.clone().into_normalized().path().as_str(),
|
||||
"nav_sections": vec!["zones", "_new-zone"],
|
||||
})
|
||||
))
|
||||
}
|
||||
|
||||
#[post("/zones/new", data = "<zone_request>")]
|
||||
pub async fn post_create_zone_page(
|
||||
conn: DbConn,
|
||||
dns_api: Box<dyn ZoneConnector>,
|
||||
user_info: models::UserInfo,
|
||||
zone_request: Form<models::CreateZoneRequest>,
|
||||
origin: &Origin<'_>
|
||||
) -> Result<Template<'static, Value>, Status> {
|
||||
user_info
|
||||
.check_admin()
|
||||
.map_err(|e| models::ErrorResponse::from(e).status)?;
|
||||
|
||||
let zone = controllers::create_zone(
|
||||
&conn,
|
||||
dns_api,
|
||||
user_info.clone(),
|
||||
zone_request.into_inner()
|
||||
).await.map_err(|e| e.status)?;
|
||||
|
||||
let zones = controllers::get_zones(
|
||||
&conn,
|
||||
user_info
|
||||
).await.map_err(|e| e.status)?;
|
||||
|
||||
Ok(Template::new(
|
||||
"pages/zones/new.html",
|
||||
json!({
|
||||
"zone": Some(zone),
|
||||
"zones": zones,
|
||||
"error": None::<String>,
|
||||
"nav_page": origin.clone().into_normalized().path().as_str(),
|
||||
"nav_sections": vec!["zones", "_new-zone"],
|
||||
})
|
||||
))
|
||||
}
|
||||
|
|
|
@ -1,63 +1,77 @@
|
|||
use std::path::Path;
|
||||
use std::process::exit;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::response::{Html, IntoResponse};
|
||||
use serde::Serialize;
|
||||
use rocket::request::Request;
|
||||
use rocket::response::{self, Responder};
|
||||
use rocket::http::{Status, ContentType};
|
||||
|
||||
use tera::{Tera, Context};
|
||||
|
||||
use crate::{errors::Error, localization::Localization};
|
||||
|
||||
pub struct TemplateState {
|
||||
tera: Tera,
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TemplateEngine {
|
||||
pub tera: Arc<Tera>,
|
||||
}
|
||||
|
||||
impl TemplateState {
|
||||
pub fn new(template_directory: &Path) -> Self {
|
||||
#[derive(Debug)]
|
||||
pub enum TemplateError {
|
||||
SerializationError { reason: Box<dyn std::error::Error> },
|
||||
RenderError { name: String, reason: Box<dyn std::error::Error> },
|
||||
}
|
||||
|
||||
impl TemplateEngine {
|
||||
pub fn new(template_directory: &Path, localization: Localization) -> Self {
|
||||
let template_glob = template_directory.join("**").join("*");
|
||||
match Tera::new(template_glob.to_str().expect("valid glob path string")) {
|
||||
Ok(tera) => TemplateState { tera },
|
||||
Ok(mut tera) => {
|
||||
tera.register_function("tr", localization);
|
||||
TemplateEngine { tera: Arc::new(tera) }
|
||||
},
|
||||
Err(e) => {
|
||||
println!("Loading templates failed: {}", e);
|
||||
exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn render<S: Serialize>(&self, name: &str, context: S) -> Result<String, TemplateError> {
|
||||
let context = Context::from_serialize(context).map_err(|e| {
|
||||
TemplateError::SerializationError { reason: Box::new(e) }
|
||||
})?;
|
||||
|
||||
let content = self.tera.render(name, &context).map_err(|e| {
|
||||
TemplateError::RenderError { name: name.into(), reason: Box::new(e) }
|
||||
})?;
|
||||
|
||||
|
||||
Ok(content)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Template<'t, S: Serialize> {
|
||||
pub name: &'t str,
|
||||
pub struct Template<'n, S: Serialize> {
|
||||
pub name: &'n str,
|
||||
pub engine: TemplateEngine,
|
||||
pub context: S,
|
||||
}
|
||||
|
||||
impl<'r, S: Serialize> Template<'r, S> {
|
||||
pub fn new(name: &'r str, context: S) -> Self {
|
||||
impl<'n, S: Serialize> Template<'n, S> {
|
||||
pub fn new(name: &'n str, engine: TemplateEngine, context: S) -> Self {
|
||||
Template {
|
||||
name,
|
||||
context
|
||||
engine,
|
||||
context,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(self, tera: &Tera) -> Result<(ContentType, String), Status> {
|
||||
let context = Context::from_serialize(self.context).map_err(|e| {
|
||||
error!("Failed to serialize context: {}", e);
|
||||
Status::InternalServerError
|
||||
})?;
|
||||
|
||||
let content = tera.render(self.name, &context).map_err(|e| {
|
||||
error!("Failed to render template `{}`: {}", self.name, e);
|
||||
Status::InternalServerError
|
||||
})?;
|
||||
|
||||
Ok((ContentType::HTML, content))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'r, 't, S: Serialize> Responder<'r, 'static> for Template<'t, S> {
|
||||
fn respond_to(self, request: &'r Request<'_>) -> response::Result<'static> {
|
||||
let template_state = request.rocket().state::<TemplateState>().ok_or(Status::InternalServerError)?;
|
||||
impl<S: Serialize> IntoResponse for Template<'_, S> {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let res = self.engine.render(self.name, self.context);
|
||||
|
||||
self.render(&template_state.tera).respond_to(request)
|
||||
match res {
|
||||
Ok(content) => Html(content).into_response(),
|
||||
Err(err) => Error::from(err).into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
121
src/validation.rs
Normal file
121
src/validation.rs
Normal file
|
@ -0,0 +1,121 @@
|
|||
use crate::errors::Error;
|
||||
|
||||
pub enum DomainValidationError {
|
||||
EmptyDomain,
|
||||
DomainTooLong { length: usize },
|
||||
CharactersNotPermitted { label: String },
|
||||
LabelToolLong { length: usize, label: String },
|
||||
EmptyLabel
|
||||
}
|
||||
|
||||
|
||||
/// Not complete but probably good enough
|
||||
/// https://doc.zonemaster.fr/v2024.1/specifications/tests/RequirementsAndNormalizationOfDomainNames.html
|
||||
/// TODO: No support of dots in labels, how to handle RNAME in SOA?
|
||||
pub fn normalize_domain(domain_name: &str) -> Result<String, Error> {
|
||||
if domain_name.is_empty() {
|
||||
return Err(Error::from(DomainValidationError::EmptyDomain))
|
||||
}
|
||||
|
||||
let domain = domain_name.strip_suffix('.').unwrap_or(domain_name).to_lowercase();
|
||||
|
||||
if domain.len() > 255 {
|
||||
Err(Error::from(DomainValidationError::DomainTooLong { length: domain.len() }))
|
||||
} else {
|
||||
let labels = domain.split('.').collect::<Vec<_>>();
|
||||
|
||||
if labels.iter().any(|l| l.is_empty()) {
|
||||
return Err(
|
||||
Error::from(DomainValidationError::EmptyLabel)
|
||||
);
|
||||
}
|
||||
|
||||
for label in labels {
|
||||
if !label.chars().all(|c| {
|
||||
// allow for '/' for reverse zone
|
||||
c.is_ascii_alphanumeric() || c == '-' || c == '/' || c == '_'
|
||||
}) {
|
||||
return Err(
|
||||
Error::from(DomainValidationError::CharactersNotPermitted { label: label.into() })
|
||||
);
|
||||
}
|
||||
|
||||
if label.len() > 63 {
|
||||
return Err(Error::from(DomainValidationError::LabelToolLong {
|
||||
label: label.into(),
|
||||
length: label.len()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(domain)
|
||||
}
|
||||
|
||||
}
|
||||
pub enum TxtParseError {
|
||||
MissingEscape,
|
||||
NonAscii { character: String },
|
||||
BadEscapeDigitsTooShort { sequence: String },
|
||||
BadEscapeDigitsNotDigits { sequence: String },
|
||||
BadEscapeDigitIndexTooHigh { sequence: String },
|
||||
}
|
||||
|
||||
pub fn parse_txt_data(text: &str) -> Result<Vec<u8>, Vec<Error>> {
|
||||
let mut chars = text.chars();
|
||||
let mut errors = Vec::new();
|
||||
let mut data = Vec::new();
|
||||
|
||||
#[inline]
|
||||
fn printable(ch: char) -> bool {
|
||||
ch.is_ascii() && ('\u{20}'..='\u{7E}').contains(&ch)
|
||||
}
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == '\\' {
|
||||
match chars.next() {
|
||||
Some(ch) => {
|
||||
if ch.is_ascii_digit() {
|
||||
let mut digits: Vec<_> = chars.by_ref().take(2).collect();
|
||||
digits.insert(0, ch);
|
||||
if digits.len() < 3 {
|
||||
errors.push(Error::from(TxtParseError::BadEscapeDigitsTooShort { sequence: String::from_iter(digits) }))
|
||||
} else if !digits.iter().all(|c| c.is_ascii_digit()) {
|
||||
errors.push(Error::from(TxtParseError::BadEscapeDigitsNotDigits { sequence: String::from_iter(digits) }))
|
||||
} else {
|
||||
let index = {
|
||||
digits[0].to_digit(10).unwrap() * 100 +
|
||||
digits[1].to_digit(10).unwrap() * 10 +
|
||||
digits[2].to_digit(10).unwrap()
|
||||
};
|
||||
|
||||
if index > 255 {
|
||||
errors.push(Error::from(TxtParseError::BadEscapeDigitIndexTooHigh { sequence: String::from_iter(digits) }))
|
||||
}
|
||||
|
||||
data.push(index as u8)
|
||||
}
|
||||
} else if printable(ch) {
|
||||
data.push(ch as u8)
|
||||
} else {
|
||||
errors.push(Error::from(TxtParseError::NonAscii { character: ch.into() }))
|
||||
}
|
||||
},
|
||||
None => {
|
||||
errors.push(Error::from(TxtParseError::MissingEscape))
|
||||
}
|
||||
}
|
||||
} else if printable(ch) {
|
||||
data.push(ch as u8);
|
||||
} else {
|
||||
errors.push(Error::from(TxtParseError::NonAscii { character: ch.into() }))
|
||||
}
|
||||
}
|
||||
|
||||
//TODO: check txt data max length?
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(data)
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
|
@ -1,44 +1,18 @@
|
|||
{% extends "bases/base.html" %}
|
||||
{% import "macros.html" as macros %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="Principal" class="main">
|
||||
<ul>
|
||||
<li><a href="/profile">Mon profil</a></li>
|
||||
<li>
|
||||
{{ macros::nav_link(
|
||||
content="Mes zones",
|
||||
href="/zones",
|
||||
current_page=nav_page,
|
||||
section="zones",
|
||||
current_sections=nav_sections,
|
||||
) }}
|
||||
<ul>
|
||||
{% for zone in zones %}
|
||||
<li>
|
||||
{{ macros::nav_link(
|
||||
content=zone.name,
|
||||
href="/zone/" ~ zone.name,
|
||||
current_page=nav_page,
|
||||
section=zone.name,
|
||||
current_sections=nav_sections,
|
||||
) }}
|
||||
</li>
|
||||
{% endfor %}
|
||||
<li>
|
||||
{{ macros::nav_link(
|
||||
safe_content='<img alt="" src="/images/plus.svg"> Ajouter une zone',
|
||||
href="/zones/new",
|
||||
current_page=nav_page,
|
||||
section="_new-zone",
|
||||
current_sections=nav_sections,
|
||||
) }}
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li><a href="#">Account</a></li>
|
||||
<li><a href="#">Zones</a></li>
|
||||
<li><a href="#">Admin</a></li>
|
||||
</ul>
|
||||
</nav>
|
||||
<main>
|
||||
{% block main %}{% endblock main %}
|
||||
</main>
|
||||
{% endblock content %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/assets/scripts/add-form-row.js"></script>
|
||||
{% endblock scripts %}
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="{{ lang }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}{% endblock title %}Nomilo</title>
|
||||
<link rel="stylesheet" type="text/css" href="/styles/main.css">
|
||||
<link rel="stylesheet" type="text/css" href="/assets/styles/main.css">
|
||||
{% block styles %}{% endblock styles %}
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
{% macro nav_link(content, href, current_page, content='', safe_content='', section=False, current_sections=False, props='') %}
|
||||
<a
|
||||
href="{{ href }}"
|
||||
{{ props }}
|
||||
{% if current_page == href %}
|
||||
aria-current="page"
|
||||
{% elif section and section in current_sections %}
|
||||
aria-current="location"
|
||||
{% endif %}
|
||||
>{{ content }}{{ safe_content | safe }}</a>
|
||||
{% endmacro nav_link %}
|
93
templates/macros/display_rrset.html
Normal file
93
templates/macros/display_rrset.html
Normal file
|
@ -0,0 +1,93 @@
|
|||
{% macro rrset(record, zone, lang) %}
|
||||
<li class="rrset">
|
||||
<div class="rtype">
|
||||
{% if record.record_type == "service" %}
|
||||
{% if record.service.service_type.service_type == "other" %}
|
||||
{{ record.service.service_type.name }}/{{ record.service.service_type.protocol }}
|
||||
{% else %}
|
||||
{{ record.srvice.service_type.service_type }}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{{ tr(msg="zone-content-record-type-" ~ record.record_type, attr="type-name", lang=lang) }}
|
||||
{% endif %}
|
||||
<div class="action">
|
||||
<a class="button icon" href="#">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pencil" viewBox="0 0 16 16">
|
||||
<path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
{% if record.record_type == "addresses" %}
|
||||
{% for address in record.addresses.addresses %}
|
||||
<li>
|
||||
<div class="rdata">
|
||||
<div class="rdata-main">
|
||||
<span class="pill">
|
||||
{{ address.address }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% elif record.record_type == "mailservers" %}
|
||||
{% for mailserver in record.mailservers.mailservers %}
|
||||
<li>
|
||||
<div class="rdata-main">
|
||||
<span class="pill">
|
||||
{{ mailserver.mail_exchanger }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="rdata-complementary">
|
||||
<span class="pill">
|
||||
{{ tr(
|
||||
msg="zone-content-record-type-mailservers",
|
||||
attr="data-preference",
|
||||
preference=mailserver.preference,
|
||||
lang=lang) }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% elif record.record_type == "nameservers" %}
|
||||
{% for nameserver in record.nameservers.nameservers %}
|
||||
<li>
|
||||
<div class="rdata-main">
|
||||
<span class="pill">
|
||||
{{ nameserver.target }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% elif record.record_type == "service" %}
|
||||
{% for service_target in record.service.service_targets %}
|
||||
<li>
|
||||
<div class="rdata-main">
|
||||
<span class="pill">
|
||||
{{ service_target.server ~ ":" ~ service_target.port }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="rdata-complementary">
|
||||
<span class="pill">
|
||||
{{ tr(
|
||||
msg="zone-content-record-type-service",
|
||||
attr="data-priority",
|
||||
priority=service_target.priority,
|
||||
lang=lang) }}
|
||||
</span>
|
||||
<span class="pill">
|
||||
{{ tr(
|
||||
msg="zone-content-record-type-service",
|
||||
attr="data-weight",
|
||||
weight=service_target.weight,
|
||||
lang=lang) }}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endmacro rrset %}
|
|
@ -1,25 +0,0 @@
|
|||
{% extends "bases/base.html" %}
|
||||
|
||||
{% block title %}Se connecter ⋅ {% endblock title %}
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" type="text/css" href="/styles/login.css">
|
||||
{% endblock styles %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<form method="POST" action="/login">
|
||||
<h1>Se connecter</h1>
|
||||
{% if error %}
|
||||
<p class="feedback error" role="alert">
|
||||
{{ error }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<label for="email">Adresse e-mail</label>
|
||||
<input type="email" id="email" name="email">
|
||||
<label for="password">Mot de passe</label>
|
||||
<input type="password" id="password" name="password">
|
||||
<input type="submit" value="Se connecter">
|
||||
</form>
|
||||
</main>
|
||||
{% endblock content %}
|
20
templates/pages/new_record.html
Normal file
20
templates/pages/new_record.html
Normal file
|
@ -0,0 +1,20 @@
|
|||
{% extends "bases/app.html" %}
|
||||
{% import "macros/new_rrset.html" as new_rrset %}
|
||||
{% block title %}{{ tr(msg="new-record-title", lang=lang) }} – {{ current_zone }} ‑ {% endblock title %}
|
||||
|
||||
{% block main %}
|
||||
<h1>{{ tr(msg="record-creation-process-heading", zone=current_zone, lang=lang) }}</h1>
|
||||
|
||||
{{ errors | json_encode(pretty=true) }}
|
||||
|
||||
{% set domain_error = errors | get(key="/name", default="") %}
|
||||
|
||||
{% if not new_record_name or (new_record_name and domain_error) %}
|
||||
{% include "pages/new_record/choose_name.html" %}
|
||||
{% elif not config and not rtype %}
|
||||
{% include "pages/new_record/choose_record.html" %}
|
||||
{% else %}
|
||||
{% include "pages/new_record/configure_record.html" %}
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
28
templates/pages/new_record/choose_name.html
Normal file
28
templates/pages/new_record/choose_name.html
Normal file
|
@ -0,0 +1,28 @@
|
|||
<h2>{{ tr(msg="record-choose-name-heading", lang=lang) }}</h2>
|
||||
<form action="" method="GET">
|
||||
<div class="form-input">
|
||||
<label for="name">{{ tr(msg="record-input-name", attr="input-label", lang=lang) }}</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
{% if domain_error %}aria-invalid="true"{% endif %}
|
||||
aria-describedby="{% if domain_error %}name-error {% endif %}subdomain-help"
|
||||
value="{{ new_record_name | default(value="") }}"
|
||||
>
|
||||
<span>.{{ current_zone }}</span>
|
||||
</div>
|
||||
</div>
|
||||
{% if domain_error %}
|
||||
<p class="error" id="name-error">
|
||||
{{ tr(
|
||||
msg="record-input-name",
|
||||
attr="error-" ~ domain_error.code | replace(from=":", to="-"),
|
||||
extra_args=domain_error | get(key="details", default=""),
|
||||
lang=lang) }}
|
||||
</p>
|
||||
{% endif %}
|
||||
<p id="name-help">{{ tr(msg="record-input-name", attr="help-description", zone=current_zone, lang=lang) }}</p>
|
||||
<button type="submit">{{ tr(msg="button-create-record-next-step", lang=lang) }}</button>
|
||||
</form>
|
30
templates/pages/new_record/choose_record.html
Normal file
30
templates/pages/new_record/choose_record.html
Normal file
|
@ -0,0 +1,30 @@
|
|||
<h2>Configure the domain {{ new_record_name }}...</h2>
|
||||
<ul>
|
||||
<li><a href="{{ url }}&config=web">Web site</a></li>
|
||||
<li><a href="{{ url }}&config=mail">E-mails</a></li>
|
||||
</ul>
|
||||
<h2>...or create a new record for the domain {{ new_record_name }}</h2>
|
||||
<h3>General</h3>
|
||||
<ul>
|
||||
<li><a href="{{ url }}&rtype=address">Address (A or AAAA)</a></li>
|
||||
<li><a href="{{ url }}&rtype=alias">Alias (CNAME)</a></li>
|
||||
<li><a href="{{ url }}&rtype=text">Text (TXT)</a></li>
|
||||
<li><a href="{{ url }}&rtype=service">Service (SRV)</a></li>
|
||||
</ul>
|
||||
<h3>E-mails</h3>
|
||||
<ul>sdv
|
||||
<li><a href="{{ url }}&rtype=service">Mail servers (MX)</a></li>
|
||||
<li><a href="{{ url }}&rtype=spf">Sender policy (SPF)</a></li>
|
||||
<li><a href="{{ url }}&rtype=dkim">Cryptographic signature (DKIM)</a></li>
|
||||
<li><a href="{{ url }}&rtype=dmarc">Error reporting (DMARC)</a></li>
|
||||
</ul>
|
||||
<h3>Security</h3>
|
||||
<ul>
|
||||
<li><a href="{{ url }}&rtype=dane">Domain authentication (TLSA)</a></li>
|
||||
<li><a href="{{ url }}&rtype=sshfp">SSH keys fingerprint (SSHFP)</a></li>
|
||||
</ul>
|
||||
<h3>DNS Delegation</h3>
|
||||
<ul>
|
||||
<li><a href="{{ url }}&rtype=nameserver">Nameserver (NS)</a></li>
|
||||
<li><a href="{{ url }}&rtype=ds">Delegation signer (DS)</a></li>
|
||||
</ul>
|
264
templates/pages/new_record/configure_record.html
Normal file
264
templates/pages/new_record/configure_record.html
Normal file
|
@ -0,0 +1,264 @@
|
|||
{% if config == "web" %}
|
||||
|
||||
<h2>{{ tr(msg="record-config-web-heading", name=new_record_name, lang=lang) }}</h2>
|
||||
|
||||
<form method="post" action="">
|
||||
<h3>{{ tr(msg="record-config-section-web", attr="servers", lang=lang) }}</h3>
|
||||
|
||||
<input name="addresses[_exist]" type="hidden" value="true">
|
||||
|
||||
{% set ttl_error = errors | get(key="/addresses/ttl", default="") %}
|
||||
<div class="form-input">
|
||||
<label for="addresses-ttl">
|
||||
{{ tr(msg="record-input-ttl", attr="input-label", lang=lang) }}
|
||||
</label>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
name="addresses[ttl]"
|
||||
id="addresses-ttl"
|
||||
aria-describedby="ttl-help{% if ttl_error %} ttl-error{% endif %}"
|
||||
value="{{ input_data.addresses.ttl | default(value="") }}"
|
||||
>
|
||||
{% if ttl_error %}
|
||||
<p class="error" id="ttl-error">
|
||||
{{ tr(
|
||||
msg="record-input-ttl",
|
||||
attr="error-" ~ ttl_error.code | replace(from=":", to="-"),
|
||||
lang=lang) }}
|
||||
</p>
|
||||
{% endif %}
|
||||
<p class="help" id="ttl-help">
|
||||
{{ tr(msg="record-input-ttl", attr="help", lang=lang) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% set global_address_error = errors | get(key="/addresses/data", default="") %}
|
||||
{% for address in input_data.addresses.data.addresses | default(value=[""]) %}
|
||||
{% set address_error = errors | get(key="/addresses/data/addresses/" ~ loop.index0 ~ "/address", default="") %}
|
||||
<div class="form-input" data-new-item-template="address">
|
||||
<label
|
||||
for="address-{{ loop.index0 }}"
|
||||
data-new-item-template-attr="for"
|
||||
data-template-for="address-{i}"
|
||||
data-new-item-template-content="{{ tr(msg="record-input-addresses", attr="input-label", index="{i}", lang=lang) }}"
|
||||
>
|
||||
{{ tr(msg="record-input-addresses", attr="input-label", index=loop.index, lang=lang) }}
|
||||
</label>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
name="addresses[data][addresses][{{ loop.index0 }}][address]"
|
||||
id="address-{{ loop.index0 }}"
|
||||
data-new-item-template-attr="name id"
|
||||
data-template-name="addresses[data][addresses][{i}][address]"
|
||||
data-template-id="address-{i}"
|
||||
aria-describedby="{% if address_error %}address-{{ loop.index0 }}-error{% endif %}"
|
||||
{% if address_error %}aria-invalid="true"{% endif %}
|
||||
value="{{ address.address | default(value="") }}"
|
||||
>
|
||||
{% if global_address_error or address_error %}
|
||||
<p class="error" id="address-{{ loop.index0 }}-error" data-new-item-skip>
|
||||
{% if global_address_error %}
|
||||
{{ tr(
|
||||
msg="record-input-addresses",
|
||||
attr="error-" ~ global_address_error.code | replace(from=":", to="-"),
|
||||
lang=lang) }}
|
||||
{% else %}
|
||||
{{ tr(
|
||||
msg="record-input-addresses",
|
||||
attr="error-" ~ address_error.code | replace(from=":", to="-"),
|
||||
lang=lang) }}
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<button class="form-new-item" type="button" data-new-item="address">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-circle" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
|
||||
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4"/>
|
||||
</svg>
|
||||
{{ tr(msg="button-add-address", lang=lang) }}
|
||||
</button>
|
||||
{% endfor %}
|
||||
|
||||
<div class="form-action">
|
||||
<button type="submit">{{ tr(msg="button-save-configuration", lang=lang) }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% elif config == "mail" %}
|
||||
|
||||
<h2>{{ tr(msg="record-config-mail-heading", name=new_record_name, lang=lang) }}</h2>
|
||||
|
||||
<form method="post" action="">
|
||||
<h3>{{ tr(msg="record-config-section-mail", attr="servers", lang=lang) }}</h3>
|
||||
<input name="mailservers[_exist]" type="hidden" value="true">
|
||||
|
||||
<div class="form-input">
|
||||
<label for="mailservers-ttl">
|
||||
{{ tr(msg="record-input-ttl", attr="input-label", lang=lang) }}
|
||||
</label>
|
||||
<div>
|
||||
<input name="mailservers[ttl]" id="mailservers-ttl" type="text" value="{{ input_data.mailservers.ttl | default(value="") }}" aria-describedby="mailservers-ttl">
|
||||
<p class="help" id="mailservers-ttl-help">
|
||||
{{ tr(msg="record-input-ttl", attr="help", lang=lang) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% for mailserver in input_data.mailservers.data.mailservers | default(value=[""]) %}
|
||||
<fieldset data-new-item-template="mailserver">
|
||||
<legend data-new-item-template-content="{{ tr(msg="record-input-mailservers", attr="legend", index="{i}", lang=lang) }}">
|
||||
{{ tr(msg="record-input-mailservers", attr="legend", index=loop.index, lang=lang) }}
|
||||
</legend>
|
||||
<div class="form-input">
|
||||
<label
|
||||
for="mailserver-mail_exchanger-{{ loop.index0 }}"
|
||||
data-new-item-template-attr="for"
|
||||
data-template-for="mailserver-mail_exchanger-{i}"
|
||||
>
|
||||
{{ tr(msg="record-input-mailservers", attr="input-label-server-name", lang=lang) }}
|
||||
</label>
|
||||
<div>
|
||||
<input
|
||||
name="mailservers[data][mailservers][{{ loop.index0 }}][mail_exchanger]"
|
||||
id="mailserver-mail_exchanger-{{ loop.index0 }}"
|
||||
data-new-item-template-attr="name id"
|
||||
data-template-name="mailservers[data][mailservers][{i}][mail_exchanger]"
|
||||
data-template-id="mailserver-mail_exchanger-{i}"
|
||||
type="text"
|
||||
value="{{ mailserver.mail_exchanger | default(value="") }}"
|
||||
>
|
||||
</div>
|
||||
|
||||
<label
|
||||
for="mailserver-preference-{{ loop.index0 }}"
|
||||
data-new-item-template-attr="for"
|
||||
data-template-for="mailserver-preference-{i}"
|
||||
>
|
||||
{{ tr(msg="record-input-mailservers", attr="input-label-preference", lang=lang) }}
|
||||
</label>
|
||||
<div>
|
||||
<input
|
||||
name="mailservers[data][mailservers][{{ loop.index0 }}][preference]"
|
||||
id="mailserver-preference-{{ loop.index0 }}"
|
||||
data-new-item-template-attr="name id"
|
||||
data-template-name="mailservers[data][mailservers][{i}][preference]"
|
||||
data-template-id="mailserver-preference-{i}"
|
||||
type="text"
|
||||
value="{{ mailserver.preference | default(value="") }}"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
{% endfor %}
|
||||
|
||||
<button class="form-new-item" type="button" data-new-item="mailserver">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-circle" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
|
||||
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4"/>
|
||||
</svg>
|
||||
{{ tr(msg="button-add-mailserver", lang=lang) }}
|
||||
</button>
|
||||
|
||||
<h3>{{ tr(msg="record-config-section-mail", attr="security", lang=lang) }}</h3>
|
||||
<input name="spf[_exist]" type="hidden" value="true">
|
||||
|
||||
<fieldset>
|
||||
<legend>{{ tr(msg="record-input-spf", attr="legend", lang=lang) }}</legend>
|
||||
<div class="form-input">
|
||||
<label for="spf-policy">
|
||||
{{ tr(msg="record-input-spf", attr="input-label", lang=lang) }}
|
||||
</label>
|
||||
<div>
|
||||
<input name="spf[data][policy]" id="spf-policy" type="text" value="{{ input_data.spf.data.policy | default(value="") }}">
|
||||
</div>
|
||||
|
||||
<label for="spf-ttl">
|
||||
{{ tr(msg="record-input-ttl", attr="input-label", lang=lang) }}
|
||||
</label>
|
||||
<div>
|
||||
<input name="spf[ttl]" id="spf-ttl" type="text" value="{{ input_data.spf.ttl | default(value="") }}" aria-describedby="spf-ttl-help">
|
||||
<p class="help" id="spf-ttl-help">
|
||||
{{ tr(msg="record-input-ttl", attr="help", lang=lang) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>{{ tr(msg="record-input-dmarc", attr="legend", lang=lang) }}</legend>
|
||||
<div class="form-input">
|
||||
<label for="dmarc">
|
||||
{{ tr(msg="record-input-dmarc", attr="input-label", lang=lang) }}
|
||||
</label>
|
||||
<div>
|
||||
<input name="dmarc" id="dmarc" type="text">
|
||||
</div>
|
||||
|
||||
<label for="dmarc-ttl">
|
||||
{{ tr(msg="record-input-ttl", attr="input-label", lang=lang) }}
|
||||
</label>
|
||||
<div>
|
||||
<input name="dmarc[ttl]" id="dmarc-ttl" type="text" value="{{ input_data.dmarc.ttl | default(value="") }}" aria-describedby="dmac-ttl-help">
|
||||
<p class="help" id="dmarc-ttl-help">
|
||||
{{ tr(msg="record-input-ttl", attr="help", lang=lang) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<h3>{{ tr(msg="record-config-section-mail", attr="dkim", lang=lang) }}</h3>
|
||||
|
||||
<div class="form-input">
|
||||
<label for="dkim-ttl">
|
||||
{{ tr(msg="record-input-ttl", attr="input-label", lang=lang) }}
|
||||
</label>
|
||||
<div>
|
||||
<input name="dkim[ttl]" id="dkim-ttl" type="text" value="{{ input_data.dkim.ttl | default(value="") }}" aria-describedby="dkim-ttl-help">
|
||||
<p class="help" id="dkim-ttl-help">
|
||||
{{ tr(msg="record-input-ttl", attr="help", lang=lang) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend>
|
||||
{{ tr(msg="record-input-dkim", attr="legend", index=1, lang=lang) }}
|
||||
</legend>
|
||||
|
||||
<div class="form-input">
|
||||
<label for="dkim-selector">
|
||||
{{ tr(msg="record-input-dkim", attr="input-label-selector", lang=lang) }}
|
||||
</label>
|
||||
<div>
|
||||
<input name="dkim-selector" id="dkim-selector" type="text">
|
||||
</div>
|
||||
|
||||
<label for="dkim-key">
|
||||
{{ tr(msg="record-input-dkim", attr="input-label-signing-key", lang=lang) }}
|
||||
</label>
|
||||
<div>
|
||||
<textarea name="dkim-key" id="dkim-key"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<button class="form-new-item" type="button" data-new-item="dmark-key">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-circle" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
|
||||
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4"/>
|
||||
</svg>
|
||||
{{ tr(msg="button-add-dkim-key", lang=lang) }}
|
||||
</button>
|
||||
|
||||
|
||||
<div class="form-action">
|
||||
<button type="submit">{{ tr(msg="button-save-configuration", lang=lang) }}</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endif %}
|
102
templates/pages/records.html
Normal file
102
templates/pages/records.html
Normal file
|
@ -0,0 +1,102 @@
|
|||
{% import "macros/display_rrset.html" as rrset %}
|
||||
{% extends "bases/app.html" %}
|
||||
|
||||
{% block title %}{{ tr(msg="zone-content-title", lang=lang) }} – {{ current_zone }} – {% endblock title %}
|
||||
|
||||
{% block main %}
|
||||
<h1>{{ tr(msg="zone-header", lang=lang, zone_name="<strong>" ~ current_zone ~ "</strong>") | safe }}</h1>
|
||||
<svg width="0" height="0" aria-hidden="true" style="position: absolute;">
|
||||
<defs>
|
||||
<clipPath id="corner-folder-tab-right" clipPathUnits="objectBoundingBox">
|
||||
<path d="m 0,0 c .25,0 0.75,1 1,1 l -1,0 z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<section>
|
||||
<h2>{{ tr(msg="zone-content-records-header", lang=lang) }}</h2>
|
||||
{% for node in records.records %}
|
||||
<article class="domain">
|
||||
<header>
|
||||
<h3 class="folder-tab">{{ node.name }}</h3>
|
||||
<span class="sep"></span>
|
||||
<a href="{{ url }}/new?name={{ node.name | trim_end_matches(pat=current_zone) | trim_end_matches(pat=".") }}" class="button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-circle" viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M8 15A7 7 0 1 1 8 1a7 7 0 0 1 0 14m0 1A8 8 0 1 0 8 0a8 8 0 0 0 0 16"/>
|
||||
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4"/>
|
||||
</svg>
|
||||
{{ tr(msg="zone-content-new-record-button", lang=lang) }}
|
||||
</a>
|
||||
</header>
|
||||
{% set sections = node.records | group_by(attribute="record_section") %}
|
||||
<div class="records">
|
||||
{% if sections.web %}
|
||||
{% set records = sections.web | group_by(attribute="record_type") %}
|
||||
<h4>{{ tr(msg="zone-content-section-web-header", lang=lang) }}</h4>
|
||||
<ul>
|
||||
{% if records.addresses %}
|
||||
{{ rrset::rrset(
|
||||
record=records.addresses.0,
|
||||
zone=current_zone,
|
||||
lang=lang) }}
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if sections.mail %}
|
||||
{% set records = sections.mail | group_by(attribute="record_type") %}
|
||||
<h4>{{ tr(msg="zone-content-section-mail-header", lang=lang) }}</h4>
|
||||
<ul>
|
||||
{% if records.mailservers %}
|
||||
{{ rrset::rrset(
|
||||
record=records.mailservers.0,
|
||||
zone=current_zone,
|
||||
lang=lang) }}
|
||||
{% endif %}
|
||||
{% if records.spf %}
|
||||
{{ rrset::rrset(
|
||||
record=records.spf.0,
|
||||
zone=current_zone,
|
||||
lang=lang) }}
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if sections.services %}
|
||||
<h4>{{ tr(msg="zone-content-section-services-header", lang=lang) }}</h4>
|
||||
<ul>
|
||||
{% for service in sections.services %}
|
||||
{{ rrset::rrset(
|
||||
record=service,
|
||||
zone=current_zone,
|
||||
lang=lang) }}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% if sections.miscellaneous %}
|
||||
<h4>{{ tr(msg="zone-content-section-general-header", lang=lang) }}</h4>
|
||||
<ul>
|
||||
{% for record in sections.miscellaneous %}
|
||||
{{ rrset::rrset(
|
||||
record=record,
|
||||
zone=current_zone,
|
||||
lang=lang) }}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
<section>
|
||||
<h2>{{ tr(msg="zone-content-aliases-header", lang=lang) }}</h2>
|
||||
|
||||
<ul>
|
||||
{% for alias in records.aliases %}
|
||||
<li>{{ alias.from }} → {{ alias.target }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endblock main %}
|
|
@ -1,43 +0,0 @@
|
|||
{% extends "bases/app.html" %}
|
||||
{% import "macros.html" as macros %}
|
||||
|
||||
{% block title %}{{ current_zone }} ⋅ Records ⋅ {% endblock title %}
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" type="text/css" href="/styles/zone.css">
|
||||
{% endblock styles %}
|
||||
|
||||
{% block main %}
|
||||
<h1>Gestion de la zone {{ current_zone }}</h1>
|
||||
<nav class="secondary" aria-label="Secondaire">
|
||||
<ul>
|
||||
<li>
|
||||
{{ macros::nav_link(
|
||||
content="Enregistrements",
|
||||
href="/zone/" ~ current_zone ~ "/records",
|
||||
current_page=nav_page,
|
||||
) }}
|
||||
</li>
|
||||
<li>
|
||||
{{ macros::nav_link(
|
||||
content="Membres",
|
||||
href="/zone/" ~ current_zone ~ "/members",
|
||||
current_page=nav_page,
|
||||
) }}
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<section>
|
||||
<zone-content>
|
||||
</zone-content>
|
||||
</section>
|
||||
{% endblock main %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="module">
|
||||
const zoneName = '{{ current_zone }}';
|
||||
|
||||
import initRecordsComponent from '/scripts/records.js';
|
||||
|
||||
initRecordsComponent(document.querySelector('zone-content'), { zone: zoneName });
|
||||
</script>
|
||||
{% endblock scripts %}
|
|
@ -1,3 +0,0 @@
|
|||
{% extends "bases/app.html" %}
|
||||
|
||||
{% block title %}Zones ⋅ {% endblock title %}
|
|
@ -1,18 +0,0 @@
|
|||
{% extends "bases/app.html" %}
|
||||
|
||||
{% block title %}Ajouter une zone ⋅ {% endblock title %}
|
||||
|
||||
{% block main %}
|
||||
<h1>Ajouter une zone</h1>
|
||||
<form method="POST" action="/zones/new">
|
||||
{% if error %}
|
||||
<p class="feedback error" role="alert">
|
||||
{{ error }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<label for="zone_name">Nom de la zone</label>
|
||||
<input type="text" id="zone_name" name="name">
|
||||
<input type="submit" value="Créer la zone">
|
||||
</form>
|
||||
{% endblock main %}
|
Loading…
Reference in a new issue