Compare commits
No commits in common. "rewrite-v0.2" and "main" have entirely different histories.
rewrite-v0
...
main
53 changed files with 3891 additions and 3373 deletions
2229
Cargo.lock
generated
2229
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
40
Cargo.toml
40
Cargo.toml
|
@ -1,27 +1,35 @@
|
|||
[package]
|
||||
name = "nomilo"
|
||||
version = "0.2.0-dev"
|
||||
authors = ["DNS Witch Collective <dns-witch@dns-witch.eu.org>"]
|
||||
version = "0.1.0-dev"
|
||||
authors = ["DNS Witch Collective <dns-witch@familier.net.eu.org>"]
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-or-later"
|
||||
readme = "README.md"
|
||||
repository = "https://git.familier.net.eu.org/dns-witch/nomilo"
|
||||
|
||||
[dependencies]
|
||||
# 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"
|
||||
#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"
|
||||
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}
|
||||
domain = { version = "0.10.3", features = [ "tsig", "unstable-client-transport" ]}
|
||||
axum = { version = "0.8.0-rc.1", default-features = false, features = [ "http1", "json", "form", "query", "tokio" ]}
|
||||
bb8 = { version = "0.9" }
|
||||
rusqlite = { version = "0.32"}
|
||||
async-trait = { version = "0.1" }
|
||||
tower-http = { version = "0.6", default-features = false, features = [ "fs" ]}
|
||||
# From trust-dns-client
|
||||
futures-util = { version = "0.3", default-features = false, features = ["std"] }
|
||||
# From rocket / cookie-rs
|
||||
time = "0.3"
|
||||
|
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -1,201 +0,0 @@
|
|||
@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;
|
||||
}
|
||||
|
||||
article.domain {
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
|
||||
article.domain header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 3em;
|
||||
}
|
||||
|
||||
article.domain header h3.folder-tab {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 0 1em;
|
||||
border-top-left-radius: .3rem;
|
||||
background-color: #f2e0fd;
|
||||
margin: 0;
|
||||
font-weight: inherit;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
article.domain header h3.folder-tab ~ .sep {
|
||||
content: '';
|
||||
width: 3em;
|
||||
background-color: #f2e0fd;
|
||||
height: 100%;
|
||||
clip-path: url("#corner-folder-tab-right");
|
||||
}
|
||||
|
||||
article.domain .records > ul {
|
||||
background: #f2e0fd;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
padding: 1rem;;
|
||||
border-radius: 0 .3rem .3rem .3rem;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
row-gap: 1rem;
|
||||
column-gap: 0;
|
||||
}
|
||||
|
||||
article.domain .records .rrset {
|
||||
display: grid;
|
||||
align-items: baseline;
|
||||
grid-template-columns: subgrid;
|
||||
grid-column: 1 / 3;
|
||||
}
|
||||
|
||||
article.domain .records .rrset .rtype {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: .5em;
|
||||
}
|
||||
|
||||
article.domain .records .rrset .rtype::after {
|
||||
content: '';
|
||||
display: block;
|
||||
flex: 1;
|
||||
border-bottom: .2rem solid #850085;
|
||||
padding-left: 1em;
|
||||
position: relative;
|
||||
bottom: .25rem;
|
||||
}
|
||||
|
||||
article.domain .records .rrset ul {
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
|
||||
article.domain .records .rrset li {
|
||||
align-items: baseline;
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
}
|
||||
|
||||
article.domain .records .rrset li::before {
|
||||
content: '';
|
||||
height: 1em;
|
||||
width: 1rem;
|
||||
border-bottom: .2rem solid #850085;
|
||||
position: relative;
|
||||
bottom: .25rem;
|
||||
}
|
||||
|
||||
article.domain .records .rrset li:not(:first-child)::before {
|
||||
border-left: .2rem solid #850085;
|
||||
border-bottom-left-radius: .3rem;
|
||||
}
|
||||
|
||||
article.domain .records .rrset li:not(:last-child)::after {
|
||||
content: '';
|
||||
height: 100%;
|
||||
width: 1rem;
|
||||
position: absolute;
|
||||
top: 1.1em;
|
||||
border-left: .2rem solid #850085;
|
||||
}
|
||||
|
||||
article.domain .records .rrset .rdata-main {
|
||||
display: flex;
|
||||
gap: .3rem;
|
||||
}
|
||||
|
||||
article.domain .records .rrset .rdata-main .pill {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
article.domain .records .rrset .rdata-complementary {
|
||||
margin-top: .2em;
|
||||
font-size: .9em;
|
||||
gap: .2rem;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
article.domain .records .rrset .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;
|
||||
}
|
||||
|
||||
button svg,
|
||||
a.button svg {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
.records button,
|
||||
.records a.button {
|
||||
background-color: #f2e0fd;
|
||||
}
|
||||
|
||||
button.icon,
|
||||
a.button.icon {
|
||||
padding: 0;
|
||||
width: 2em;
|
||||
height: 2em;
|
||||
}
|
||||
|
||||
button:hover,
|
||||
button:focus-visible,
|
||||
a.button:hover,
|
||||
a.button:focus-visible {
|
||||
color: white;
|
||||
background-color: #850085;
|
||||
}
|
|
@ -1,58 +0,0 @@
|
|||
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,16 +1,8 @@
|
|||
services:
|
||||
|
||||
knot:
|
||||
image: cznic/knot:3.4
|
||||
image: cznic/knot
|
||||
volumes:
|
||||
- ./zones:/storage/zones:ro
|
||||
- ./config:/config:ro
|
||||
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
|
||||
command: knotd
|
||||
network_mode: host
|
||||
|
|
|
@ -7,7 +7,6 @@ example.com. IN SOA ns.example.com. admin.example.com. (
|
|||
)
|
||||
|
||||
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
|
||||
|
|
3
public/images/plus.svg
Normal file
3
public/images/plus.svg
Normal file
|
@ -0,0 +1,3 @@
|
|||
<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>
|
After Width: | Height: | Size: 245 B |
44
public/scripts/api.js
Normal file
44
public/scripts/api.js
Normal file
|
@ -0,0 +1,44 @@
|
|||
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,
|
||||
};
|
328
public/scripts/records.js
Normal file
328
public/scripts/records.js
Normal file
|
@ -0,0 +1,328 @@
|
|||
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
Normal file
7
public/scripts/vendor/licenses.txt
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
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
Normal file
202
public/scripts/vendor/preact/LICENSE-htm
vendored
Normal file
|
@ -0,0 +1,202 @@
|
|||
|
||||
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
Normal file
21
public/scripts/vendor/preact/LICENSE-preact
vendored
Normal file
|
@ -0,0 +1,21 @@
|
|||
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
Normal file
1
public/scripts/vendor/preact/standalone.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
public/styles/login.css
Normal file
13
public/styles/login.css
Normal file
|
@ -0,0 +1,13 @@
|
|||
form {
|
||||
flex-grow: 1;
|
||||
max-width: 40ch;
|
||||
margin: 25vh auto 0 auto;
|
||||
}
|
||||
|
||||
main {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
}
|
158
public/styles/main.css
Normal file
158
public/styles/main.css
Normal file
|
@ -0,0 +1,158 @@
|
|||
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;
|
||||
}
|
183
public/styles/zone.css
Normal file
183
public/styles/zone.css
Normal file
|
@ -0,0 +1,183 @@
|
|||
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)
|
||||
}
|
|
@ -1,65 +0,0 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use crate::ressouces::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
|
||||
}
|
||||
}
|
||||
}
|
271
src/dns/dns_connector.rs
Normal file
271
src/dns/dns_connector.rs
Normal file
|
@ -0,0 +1,271 @@
|
|||
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,568 +0,0 @@
|
|||
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::ressouces::{rdata, record};
|
||||
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<Vec<record::Record>, 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::<rdata::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(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: &[record::DnsRecordImpl]) -> 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 {
|
||||
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) }
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
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,38 +1,3 @@
|
|||
pub mod dns_driver;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::ressouces::record;
|
||||
|
||||
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<Vec<record::Record>, DnsDriverError>;
|
||||
async fn add_records(&self, zone: &str, new_records: &[record::DnsRecordImpl]) -> 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;
|
||||
|
@ -88,4 +53,3 @@ impl<'r> FromRequest<'r> for Box<dyn ZoneConnector> {
|
|||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
|
389
src/errors.rs
389
src/errors.rs
|
@ -1,389 +0,0 @@
|
|||
use std::fmt;
|
||||
|
||||
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::ressouces::record::{RecordError, RecordParseError};
|
||||
use crate::ressouces::zone::ZoneError;
|
||||
use crate::validation::{DomainValidationError, TxtParseError};
|
||||
use crate::template::TemplateError;
|
||||
|
||||
#[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()
|
||||
}
|
||||
}
|
||||
|
||||
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 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<RecordParseError> for Error {
|
||||
fn from(value: RecordParseError) -> Self {
|
||||
match value {
|
||||
RecordParseError::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
|
||||
}))
|
||||
},
|
||||
RecordParseError::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
|
||||
}))
|
||||
},
|
||||
RecordParseError::RDataUnknown { input, field, rtype } => {
|
||||
Error::new("record:parse:rdata_unknown", "Unknown error while parsing record rdata field")
|
||||
.with_details(json!({
|
||||
"input": input,
|
||||
"field": field,
|
||||
"rtype": rtype,
|
||||
}))
|
||||
},
|
||||
RecordParseError::NameUnknown { input } => {
|
||||
Error::new("record:parse:name_unknown", "Unknown error while parsing record name")
|
||||
.with_details(json!({
|
||||
"input": input
|
||||
}))
|
||||
},
|
||||
RecordParseError::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<TemplateError> for Error {
|
||||
fn from(value: TemplateError) -> Self {
|
||||
match value {
|
||||
TemplateError::RenderError { name, reason } => {
|
||||
Error::new("template:render", "Failed to render the template")
|
||||
.with_details(json!({
|
||||
"name": name
|
||||
}))
|
||||
.with_cause(&reason.to_string())
|
||||
},
|
||||
TemplateError::SerializationError { reason } => {
|
||||
Error::new("template:serialization", "Failed to serialize context")
|
||||
.with_cause(&reason.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
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)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
pub(crate) use append_errors;
|
||||
pub(crate) use push_error;
|
84
src/main.rs
84
src/main.rs
|
@ -1,73 +1,26 @@
|
|||
mod errors;
|
||||
mod dns;
|
||||
#![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 ressouces;
|
||||
mod database;
|
||||
mod validation;
|
||||
mod macros;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod dns;
|
||||
mod models;
|
||||
mod schema;
|
||||
mod template;
|
||||
mod controllers;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::process::exit;
|
||||
|
||||
use axum::Router;
|
||||
use axum::routing;
|
||||
use tower_http::services::ServeDir;
|
||||
use clap::Parser;
|
||||
use figment::{Figment, Profile, providers::{Format, Toml, Env}};
|
||||
use rocket_sync_db_pools::database;
|
||||
use diesel::prelude::*;
|
||||
|
||||
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 template_engine = TemplateEngine::new(std::path::Path::new("./templates"));
|
||||
|
||||
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_zone_records_page))
|
||||
.nest_service("/assets", ServeDir::new("assets"))
|
||||
.with_state(app_state);
|
||||
|
||||
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")]
|
||||
|
@ -110,4 +63,3 @@ fn main() {
|
|||
let nomilo = NomiloCli::parse();
|
||||
nomilo.run(figment, app_config);
|
||||
}
|
||||
*/
|
||||
|
|
40
src/models/class.rs
Normal file
40
src/models/class.rs
Normal file
|
@ -0,0 +1,40 @@
|
|||
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),
|
||||
}
|
||||
}
|
||||
}
|
176
src/models/errors.rs
Normal file
176
src/models/errors.rs
Normal file
|
@ -0,0 +1,176 @@
|
|||
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,4 +1,3 @@
|
|||
/*
|
||||
pub mod class;
|
||||
pub mod errors;
|
||||
pub mod name;
|
||||
|
@ -17,8 +16,3 @@ 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 rdata;
|
||||
pub mod record;
|
94
src/models/name.rs
Normal file
94
src/models/name.rs
Normal file
|
@ -0,0 +1,94 @@
|
|||
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
|
||||
}
|
||||
}
|
258
src/models/rdata.rs
Normal file
258
src/models/rdata.rs
Normal file
|
@ -0,0 +1,258 @@
|
|||
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!(),
|
||||
})
|
||||
}
|
||||
}
|
122
src/models/record.rs
Normal file
122
src/models/record.rs
Normal file
|
@ -0,0 +1,122 @@
|
|||
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,4 +1,3 @@
|
|||
/*
|
||||
use uuid::Uuid;
|
||||
use diesel::prelude::*;
|
||||
use diesel::result::Error as DieselError;
|
||||
|
@ -235,4 +234,3 @@ impl LocalUser {
|
|||
})
|
||||
}
|
||||
}
|
||||
*/
|
93
src/models/zone.rs
Normal file
93
src/models/zone.rs
Normal file
|
@ -0,0 +1,93 @@
|
|||
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,544 +0,0 @@
|
|||
use std::fmt::Write;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use domain::base::rdata::ComposeRecordData;
|
||||
use domain::base::scan::Symbol;
|
||||
use domain::base::wire::{Composer, ParseError};
|
||||
use domain::base::{Name, ParseRecordData, ParsedName, RecordData, Rtype, ToName, Ttl};
|
||||
use domain::rdata;
|
||||
use domain::dep::octseq::{Parser, Octets};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::errors::Error;
|
||||
use crate::validation;
|
||||
|
||||
use crate::macros::{append_errors, push_error};
|
||||
use super::record::RecordParseError;
|
||||
|
||||
/// 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 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 enum ParsedRData<Name, Octs> {
|
||||
A(rdata::A),
|
||||
Aaaa(rdata::Aaaa),
|
||||
Cname(rdata::Cname<Name>),
|
||||
Mx(rdata::Mx<Name>),
|
||||
Ns(rdata::Ns<Name>),
|
||||
Ptr(rdata::Ptr<Name>),
|
||||
Soa(rdata::Soa<Name>),
|
||||
Srv(rdata::Srv<Name>),
|
||||
Txt(rdata::Txt<Octs>),
|
||||
}
|
||||
|
||||
impl<Name: ToString, Octs: AsRef<[u8]>> From<ParsedRData<Name, Octs>> for RData {
|
||||
fn from(value: ParsedRData<Name, Octs>) -> Self {
|
||||
match value {
|
||||
ParsedRData::A(record_rdata) => RData::A(record_rdata.into()),
|
||||
ParsedRData::Aaaa(record_rdata) => RData::Aaaa(record_rdata.into()),
|
||||
ParsedRData::Cname(record_rdata) => RData::Cname(record_rdata.into()),
|
||||
ParsedRData::Mx(record_rdata) => RData::Mx(record_rdata.into()),
|
||||
ParsedRData::Ns(record_rdata) => RData::Ns(record_rdata.into()),
|
||||
ParsedRData::Ptr(record_rdata) => RData::Ptr(record_rdata.into()),
|
||||
ParsedRData::Soa(record_rdata) => RData::Soa(record_rdata.into()),
|
||||
ParsedRData::Srv(record_rdata) => RData::Srv(record_rdata.into()),
|
||||
ParsedRData::Txt(record_rdata) => RData::Txt(record_rdata.into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<RData> for ParsedRData<Name<Vec<u8>>, Vec<u8>> {
|
||||
type Error = Vec<Error>;
|
||||
|
||||
fn try_from(value: RData) -> Result<Self, Self::Error> {
|
||||
let rdata = match value {
|
||||
RData::A(record_rdata) => ParsedRData::A(record_rdata.parse_record()?),
|
||||
RData::Aaaa(record_rdata) => ParsedRData::Aaaa(record_rdata.parse_record()?),
|
||||
RData::Cname(record_rdata) => ParsedRData::Cname(record_rdata.parse_record()?),
|
||||
RData::Mx(record_rdata) => ParsedRData::Mx(record_rdata.parse_record()?),
|
||||
RData::Ns(record_rdata) => ParsedRData::Ns(record_rdata.parse_record()?),
|
||||
RData::Ptr(record_rdata) => ParsedRData::Ptr(record_rdata.parse_record()?),
|
||||
RData::Soa(record_rdata) => ParsedRData::Soa(record_rdata.parse_record()?),
|
||||
RData::Srv(record_rdata) => ParsedRData::Srv(record_rdata.parse_record()?),
|
||||
RData::Txt(record_rdata) => ParsedRData::Txt(record_rdata.parse_record()?),
|
||||
};
|
||||
Ok(rdata)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
macro_rules! parse_name {
|
||||
($value:expr, $field:ident, $rtype:literal, $errors:expr) => {
|
||||
{
|
||||
let name = push_error!(
|
||||
validation::normalize_domain(&$value.$field),
|
||||
$errors, concat!("/", stringify!($field))
|
||||
);
|
||||
|
||||
let name = name.and_then(|name| {
|
||||
push_error!(
|
||||
name.parse::<Name<_>>().map_err(|e| {
|
||||
Error::from(RecordParseError::RDataUnknown {
|
||||
input: $value.$field,
|
||||
field: stringify!(field).to_string(),
|
||||
rtype: $rtype.to_string(),
|
||||
}).with_cause(&e.to_string())
|
||||
}),
|
||||
$errors, concat!("/", stringify!($field))
|
||||
)
|
||||
});
|
||||
|
||||
name
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* --------- A --------- */
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct A {
|
||||
pub address: String,
|
||||
}
|
||||
|
||||
impl From<rdata::A> for A {
|
||||
fn from(record_data: rdata::A) -> Self {
|
||||
A { address: record_data.addr().to_string() }
|
||||
}
|
||||
}
|
||||
|
||||
impl A {
|
||||
pub fn parse_record(self) -> Result<rdata::A, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let address = push_error!(self.address.parse::<Ipv4Addr>().map_err(|e| {
|
||||
Error::from(RecordParseError::Ip4Address { input: self.address })
|
||||
.with_cause(&e.to_string())
|
||||
.with_path("/address")
|
||||
}), errors);
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(rdata::A::new(address.unwrap()))
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- AAAA --------- */
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Aaaa {
|
||||
pub address: String,
|
||||
}
|
||||
|
||||
impl From<rdata::Aaaa> for Aaaa {
|
||||
fn from(record_data: rdata::Aaaa) -> Self {
|
||||
Aaaa { address: record_data.addr().to_string() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Aaaa {
|
||||
pub fn parse_record(self) -> Result<rdata::Aaaa, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let address = push_error!(self.address.parse::<Ipv6Addr>().map_err(|e| {
|
||||
Error::from(RecordParseError::Ip6Address { input: self.address })
|
||||
.with_cause(&e.to_string())
|
||||
.with_path("/address")
|
||||
}), errors);
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(rdata::Aaaa::new(address.unwrap()))
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- CNAME --------- */
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Cname {
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
impl<N: ToString> From<rdata::Cname<N>> for Cname {
|
||||
fn from(record_data: rdata::Cname<N>) -> Self {
|
||||
Cname { target: record_data.cname().to_string() }
|
||||
}
|
||||
}
|
||||
|
||||
impl Cname {
|
||||
pub fn parse_record(self) -> Result<rdata::Cname<Name<Vec<u8>>>, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let cname = parse_name!(self, target, "CNAME", errors);
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(rdata::Cname::new(cname.unwrap()))
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- MX --------- */
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Mx {
|
||||
pub preference: u16,
|
||||
pub mail_exchanger: String,
|
||||
}
|
||||
|
||||
impl<N: ToString> From<rdata::Mx<N>> for Mx {
|
||||
fn from(record_data: rdata::Mx<N>) -> Self {
|
||||
Mx {
|
||||
preference: record_data.preference(),
|
||||
mail_exchanger: record_data.exchange().to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Mx {
|
||||
fn parse_record(self) -> Result<rdata::Mx<Name<Vec<u8>>>, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let mail_exchanger = parse_name!(self, mail_exchanger, "MX", errors);
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(rdata::Mx::new(self.preference, mail_exchanger.unwrap()))
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- NS --------- */
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Ns {
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
impl<N: ToString> From<rdata::Ns<N>> for Ns {
|
||||
fn from(record_rdata: rdata::Ns<N>) -> Self {
|
||||
Ns {
|
||||
target: record_rdata.nsdname().to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ns {
|
||||
fn parse_record(self) -> Result<rdata::Ns<Name<Vec<u8>>>, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let ns_name = parse_name!(self, target, "NS", errors);
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(rdata::Ns::new(ns_name.unwrap()))
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- PTR --------- */
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Ptr {
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
impl<N: ToString> From<rdata::Ptr<N>> for Ptr {
|
||||
fn from(record_rdata: rdata::Ptr<N>) -> Self {
|
||||
Ptr {
|
||||
target: record_rdata.ptrdname().to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ptr {
|
||||
fn parse_record(self) -> Result<rdata::Ptr<Name<Vec<u8>>>, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let ptr_name = parse_name!(self, target, "PTR", errors);
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(rdata::Ptr::new(ptr_name.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<N: ToString> From<rdata::Soa<N>> for Soa {
|
||||
fn from(record_rdata: rdata::Soa<N>) -> Self {
|
||||
Soa {
|
||||
primary_server: record_rdata.mname().to_string(),
|
||||
maintainer: 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 Soa {
|
||||
fn parse_record(self) -> Result<rdata::Soa<Name<Vec<u8>>>, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let primary_ns = parse_name!(self, primary_server, "SOA", errors);
|
||||
let maintainer = parse_name!(self, maintainer, "SOA", errors);
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(rdata::Soa::new(
|
||||
primary_ns.unwrap(),
|
||||
maintainer.unwrap(),
|
||||
self.refresh.into(),
|
||||
Ttl::from_secs(self.retry),
|
||||
Ttl::from_secs(self.expire),
|
||||
Ttl::from_secs(self.minimum),
|
||||
Ttl::from_secs(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<N: ToString> From<rdata::Srv<N>> for Srv {
|
||||
fn from(record_data: rdata::Srv<N>) -> Self {
|
||||
Srv {
|
||||
server: record_data.target().to_string(),
|
||||
priority: record_data.priority(),
|
||||
weight: record_data.weight(),
|
||||
port: record_data.port(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Srv {
|
||||
fn parse_record(self) -> Result<rdata::Srv<Name<Vec<u8>>>, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let server = parse_name!(self, server, "SRV", errors);
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(rdata::Srv::new(
|
||||
self.priority,
|
||||
self.weight,
|
||||
self.port,
|
||||
server.unwrap(),
|
||||
))
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* --------- TXT --------- */
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Txt {
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl<O: AsRef<[u8]>> From<rdata::Txt<O>> for Txt {
|
||||
fn from(record_data: rdata::Txt<O>) -> Self {
|
||||
let mut concatenated_text = String::new();
|
||||
for text in record_data.iter() {
|
||||
for c in text {
|
||||
// Escapes '\' and non printable chars
|
||||
let c = Symbol::display_from_octet(*c);
|
||||
write!(concatenated_text, "{}", c).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
Txt {
|
||||
text: concatenated_text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Txt {
|
||||
fn parse_record(self) -> Result<rdata::Txt<Vec<u8>>, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
let data = append_errors!(validation::parse_txt_data(&self.text), errors, "/text");
|
||||
let data = data.and_then(|data| {
|
||||
push_error!(rdata::Txt::build_from_slice(&data).map_err(|e| {
|
||||
Error::from(RecordParseError::RDataUnknown {
|
||||
input: self.text,
|
||||
field: "text".into(),
|
||||
rtype: "TXT".into(),
|
||||
}).with_cause(&e.to_string())
|
||||
.with_path("/text")
|
||||
}), errors)
|
||||
});
|
||||
|
||||
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(data.unwrap())
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --------- ParsedRData: domain traits impl --------- */
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,128 +0,0 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use domain::base::{iana::Class, Name, Record as DnsRecord, Ttl};
|
||||
|
||||
use crate::{errors::Error, validation};
|
||||
use crate::macros::{append_errors, push_error};
|
||||
use super::rdata::{ParsedRData, RData};
|
||||
|
||||
pub enum RecordParseError {
|
||||
Ip4Address { input: String },
|
||||
Ip6Address { input: String },
|
||||
RDataUnknown { input: String, field: String, rtype: String },
|
||||
NameUnknown { input: String },
|
||||
NotInZone { name: String, zone: String },
|
||||
}
|
||||
|
||||
pub enum RecordError {
|
||||
Validation { suberrors: Vec<Error> },
|
||||
}
|
||||
|
||||
pub(crate) type DnsRecordImpl = DnsRecord<
|
||||
Name<Vec<u8>>,
|
||||
ParsedRData<Name<Vec<u8>>,Vec<u8>>
|
||||
>;
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct Record {
|
||||
pub name: String,
|
||||
pub ttl: u32,
|
||||
#[serde(flatten)]
|
||||
pub rdata: RData
|
||||
}
|
||||
|
||||
impl<Name: ToString, Oct: AsRef<[u8]>> From<DnsRecord<Name, ParsedRData<Name, Oct>>> for Record {
|
||||
fn from(value: DnsRecord<Name, ParsedRData<Name, Oct>>) -> Self {
|
||||
Record {
|
||||
name: value.owner().to_string(),
|
||||
ttl: value.ttl().as_secs(),
|
||||
rdata: value.into_data().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Record {
|
||||
fn convert(self, zone_name: &Name<Vec<u8>>) -> Result<DnsRecordImpl, Vec<Error>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
let name = push_error!(validation::normalize_domain(&self.name), errors, "/name");
|
||||
|
||||
let name = name.and_then(|name| push_error!(name.parse::<Name<_>>().map_err(|e| {
|
||||
Error::from(RecordParseError::NameUnknown {
|
||||
input: self.name.clone()
|
||||
}).with_cause(&e.to_string())
|
||||
}), errors, "/name"));
|
||||
|
||||
let name = name.and_then(|name| {
|
||||
if !name.ends_with(zone_name) {
|
||||
errors.push(
|
||||
Error::from(RecordParseError::NotInZone { name: self.name, zone: zone_name.to_string() })
|
||||
.with_path("/name")
|
||||
);
|
||||
None
|
||||
} else {
|
||||
Some(name)
|
||||
}
|
||||
});
|
||||
|
||||
let ttl = Ttl::from_secs(self.ttl);
|
||||
let rdata = append_errors!(ParsedRData::try_from(self.rdata), errors, "/rdata");
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(DnsRecord::new(name.unwrap(), Class::IN, ttl, rdata.unwrap()))
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
pub struct RecordList(pub Vec<Record>);
|
||||
|
||||
impl RecordList {
|
||||
fn convert(self, zone_name: &Name<Vec<u8>>) -> Result<Vec<DnsRecordImpl>, 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.convert(zone_name), errors, &format!("/{index}"));
|
||||
|
||||
if let Some(record) = record {
|
||||
records.push(record)
|
||||
}
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(records)
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug,Deserialize)]
|
||||
pub struct AddRecordsRequest {
|
||||
pub new_records: RecordList
|
||||
}
|
||||
|
||||
pub struct AddRecords {
|
||||
pub new_records: Vec<DnsRecordImpl>
|
||||
}
|
||||
|
||||
impl AddRecordsRequest {
|
||||
pub fn validate(self, zone_name: &str) -> Result<AddRecords, Error> {
|
||||
let zone_name: Name<Vec<u8>> = zone_name.parse().expect("zone name is assumed to be valid");
|
||||
|
||||
let mut errors = Vec::new();
|
||||
let records = append_errors!(self.new_records.convert(&zone_name), errors, "/new_records");
|
||||
|
||||
if errors.is_empty() {
|
||||
Ok(AddRecords {
|
||||
new_records: records.unwrap(),
|
||||
})
|
||||
} else {
|
||||
Err(Error::from(RecordError::Validation { suberrors: errors }))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,236 +0,0 @@
|
|||
use async_trait::async_trait;
|
||||
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::ressouces::record::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(zone_name: &str, db: BoxedDb, record_driver: BoxedRecordDriver) ->Result<RecordList, Error> {
|
||||
let zone = db.get_zone_by_name(zone_name).await?;
|
||||
let mut records = record_driver.get_records(&zone.name).await?;
|
||||
|
||||
records.sort_by(|r1, r2| {
|
||||
let key1 = (&r1.name, r1.rdata.rtype());
|
||||
let key2 = (&r2.name, r2.rdata.rtype());
|
||||
key1.cmp(&key2)
|
||||
});
|
||||
|
||||
Ok(RecordList(records))
|
||||
}
|
||||
}
|
||||
|
||||
#[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,46 +1,3 @@
|
|||
use axum::extract::{Path, State};
|
||||
use axum::Json;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::errors::Error;
|
||||
use crate::ressouces::zone::{CreateZoneRequest, Zone};
|
||||
use crate::ressouces::record::{AddRecordsRequest, Record, RecordList};
|
||||
|
||||
|
||||
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<RecordList>, Error>
|
||||
{
|
||||
Zone::get_records(&zone_name, app.db, app.records).await.map(Json)
|
||||
}
|
||||
|
||||
pub async fn create_zone_records(
|
||||
Path(zone_name): Path<String>,
|
||||
State(app): State<AppState>,
|
||||
Json(add_records): Json<AddRecordsRequest>,
|
||||
) -> Result<Json<Vec<Record>>, Error>
|
||||
{
|
||||
|
||||
let zone = app.db.get_zone_by_name(&zone_name).await?;
|
||||
let add_records = add_records.validate(&zone.name)?;
|
||||
app.records.add_records(&zone.name, &add_records.new_records).await?;
|
||||
let records = add_records.new_records.into_iter()
|
||||
.map(|r| r.into())
|
||||
.collect();
|
||||
|
||||
Ok(Json(records))
|
||||
}
|
||||
|
||||
/*
|
||||
use rocket::http::Status;
|
||||
|
||||
use rocket::serde::json::Json;
|
||||
|
@ -221,4 +178,3 @@ pub async fn add_member_to_zone<'r>(
|
|||
|
||||
Ok(Status::Created) // TODO: change this?
|
||||
}
|
||||
*/
|
||||
|
|
|
@ -1,2 +1,5 @@
|
|||
//pub mod auth;
|
||||
pub mod auth;
|
||||
pub mod zones;
|
||||
|
||||
pub use auth::*;
|
||||
pub use zones::*;
|
||||
|
|
|
@ -1,26 +1,128 @@
|
|||
use axum::extract::{Path, State};
|
||||
use serde_json::{Value, json};
|
||||
use serde::Serialize;
|
||||
use rocket::http::{Status};
|
||||
use rocket::http::uri::Origin;
|
||||
use rocket::form::Form;
|
||||
|
||||
use crate::AppState;
|
||||
use crate::errors::Error;
|
||||
use crate::ressouces::zone::Zone;
|
||||
use crate::template::Template;
|
||||
use crate::models;
|
||||
use crate::controllers;
|
||||
use crate::DbConn;
|
||||
use crate::dns::ZoneConnector;
|
||||
|
||||
|
||||
pub async fn get_zone_records_page(
|
||||
Path(zone_name): Path<String>,
|
||||
State(app): State<AppState>,
|
||||
) -> Result<Template<'static, Value>, Error> {
|
||||
let records = Zone::get_records(&zone_name, app.db, app.records).await?;
|
||||
#[derive(Serialize)]
|
||||
pub struct RecordsPage {
|
||||
zone: 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();
|
||||
|
||||
let zones = conn.run(move |c| {
|
||||
if user_info.is_admin() {
|
||||
models::Zone::get_by_name(c, &zone_name)?;
|
||||
models::Zone::get_all(c)
|
||||
} else {
|
||||
user_info.get_zone(c, &zone_name)?;
|
||||
user_info.get_zones(c)
|
||||
|
||||
}
|
||||
}).await.map_err(|e| models::ErrorResponse::from(e).status)?;
|
||||
|
||||
//records.0.sort_by_key(|record| (&record.name, record.rdata));
|
||||
|
||||
Ok(Template::new(
|
||||
"pages/records.html",
|
||||
app.template_engine,
|
||||
"pages/zone/records.html",
|
||||
json!({
|
||||
"current_zone": zone_name,
|
||||
"records": records,
|
||||
"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,73 +1,63 @@
|
|||
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;
|
||||
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TemplateEngine {
|
||||
tera: Arc<Tera>,
|
||||
pub struct TemplateState {
|
||||
tera: Tera,
|
||||
}
|
||||
|
||||
pub enum TemplateError {
|
||||
SerializationError { reason: Box<dyn std::error::Error> },
|
||||
RenderError { name: String, reason: Box<dyn std::error::Error> },
|
||||
}
|
||||
|
||||
impl TemplateEngine {
|
||||
impl TemplateState {
|
||||
pub fn new(template_directory: &Path) -> Self {
|
||||
let template_glob = template_directory.join("**").join("*");
|
||||
match Tera::new(template_glob.to_str().expect("valid glob path string")) {
|
||||
Ok(tera) => TemplateEngine { tera: Arc::new(tera) },
|
||||
Ok(tera) => TemplateState { 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<'n, S: Serialize> {
|
||||
pub name: &'n str,
|
||||
pub engine: TemplateEngine,
|
||||
pub struct Template<'t, S: Serialize> {
|
||||
pub name: &'t str,
|
||||
pub context: S,
|
||||
}
|
||||
|
||||
impl<'n, S: Serialize> Template<'n, S> {
|
||||
pub fn new(name: &'n str, engine: TemplateEngine, context: S) -> Self {
|
||||
impl<'r, S: Serialize> Template<'r, S> {
|
||||
pub fn new(name: &'r str, context: S) -> Self {
|
||||
Template {
|
||||
name,
|
||||
engine,
|
||||
context,
|
||||
}
|
||||
context
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: Serialize> IntoResponse for Template<'_, S> {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
let res = self.engine.render(self.name, self.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
|
||||
})?;
|
||||
|
||||
match res {
|
||||
Ok(content) => Html(content).into_response(),
|
||||
Err(err) => Error::from(err).into_response(),
|
||||
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)?;
|
||||
|
||||
self.render(&template_state.tera).respond_to(request)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,116 +0,0 @@
|
|||
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> {
|
||||
let domain = domain_name.strip_prefix('.').unwrap_or(domain_name).to_lowercase();
|
||||
|
||||
if domain.is_empty() {
|
||||
Err(Error::from(DomainValidationError::EmptyDomain))
|
||||
} else if domain.as_bytes().len() > 255 {
|
||||
Err(Error::from(DomainValidationError::DomainTooLong { length: domain.as_bytes().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.as_bytes().len() > 63 {
|
||||
return Err(Error::from(DomainValidationError::LabelToolLong {
|
||||
label: label.into(),
|
||||
length: label.as_bytes().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) }))
|
||||
}
|
||||
}
|
||||
} 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,11 +1,41 @@
|
|||
{% extends "bases/base.html" %}
|
||||
{% import "macros.html" as macros %}
|
||||
|
||||
{% block content %}
|
||||
<nav aria-label="Principal" class="main">
|
||||
<ul>
|
||||
<li><a href="#">Account</a></li>
|
||||
<li><a href="#">Zones</a></li>
|
||||
<li><a href="#">Admin</a></li>
|
||||
<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>
|
||||
</ul>
|
||||
</nav>
|
||||
<main>
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
<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="/assets/styles/main.css">
|
||||
<link rel="stylesheet" type="text/css" href="/styles/main.css">
|
||||
{% block styles %}{% endblock styles %}
|
||||
</head>
|
||||
<body>
|
||||
|
|
11
templates/macros.html
Normal file
11
templates/macros.html
Normal file
|
@ -0,0 +1,11 @@
|
|||
{% 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 %}
|
25
templates/pages/login.html
Normal file
25
templates/pages/login.html
Normal file
|
@ -0,0 +1,25 @@
|
|||
{% 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 %}
|
|
@ -1,103 +0,0 @@
|
|||
{% extends "bases/app.html" %}
|
||||
{% block title %}Records - {{ current_zone }} - {% endblock title %}
|
||||
|
||||
{% block main %}
|
||||
<h1>Zone {{ current_zone }} records</h1>
|
||||
<svg width="0" height="0" aria-hidden="true">
|
||||
<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>Records</h2>
|
||||
|
||||
{% set current_domain = '' %}
|
||||
{% set current_rtype = '' %}
|
||||
|
||||
{% for record in records %}
|
||||
{% if record.name != current_domain %}
|
||||
{% if current_domain %}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
{% endif %}
|
||||
{% set_global current_domain = record.name %}
|
||||
{% set_global current_rtype = '' %}
|
||||
<article class="domain">
|
||||
<header>
|
||||
<h3 class="folder-tab">{{ record.name }}</h3>
|
||||
<span class="sep"></span>
|
||||
<a href="#" 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>
|
||||
Add record
|
||||
</a>
|
||||
</header>
|
||||
<div class="records">
|
||||
<ul>
|
||||
{% endif %}
|
||||
{% if record.type != current_rtype %}
|
||||
{% if current_rtype %}
|
||||
</ul>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% set_global current_rtype = record.type %}
|
||||
<li class="rrset">
|
||||
<span class="rtype">{{ record.type }}</span>
|
||||
<ul>
|
||||
<li>
|
||||
{% endif %}
|
||||
<div class="rdata">
|
||||
{% if record.type == "A" or record.type == "AAAA" %}
|
||||
<div class="rdata-main">
|
||||
<span class="pill">
|
||||
{{ record.rdata.address }}
|
||||
</span>
|
||||
</div>
|
||||
{% elif record.type == "MX" %}
|
||||
<div class="rdata-main">
|
||||
<span class="pill">
|
||||
{{ record.rdata.mail_exchanger }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="rdata-complementary">
|
||||
<span class="pill">
|
||||
Preference: {{ record.rdata.mail_exchanger }}
|
||||
</span>
|
||||
</div>
|
||||
{% elif record.type == "NS" %}
|
||||
<div class="rdata-main">
|
||||
<span class="pill">
|
||||
{{ record.rdata.target }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="action">
|
||||
<button class="icon">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-trash" viewBox="0 0 16 16">
|
||||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5m3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0z"/>
|
||||
<path d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4zM2.5 3h11V2h-11z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<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>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
{% endblock main %}
|
43
templates/pages/zone/records.html
Normal file
43
templates/pages/zone/records.html
Normal file
|
@ -0,0 +1,43 @@
|
|||
{% 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 %}
|
3
templates/pages/zones.html
Normal file
3
templates/pages/zones.html
Normal file
|
@ -0,0 +1,3 @@
|
|||
{% extends "bases/app.html" %}
|
||||
|
||||
{% block title %}Zones ⋅ {% endblock title %}
|
18
templates/pages/zones/new.html
Normal file
18
templates/pages/zones/new.html
Normal file
|
@ -0,0 +1,18 @@
|
|||
{% 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