testing proper SOA records
This commit is contained in:
parent
a089bfc8e5
commit
6ac53c1b05
36
TESTS.md
36
TESTS.md
|
@ -33,46 +33,56 @@ Test that A queries for ANAME-enabled records (but no address) recurse (regardle
|
|||
|
||||
Generally speaking test the cases of 0, 1, and 2 records of any given type (null case, single case, multi case)
|
||||
|
||||
```
|
||||
# Serve:
|
||||
node bin/digd.js +norecurse -p 65053 --input sample/db.json
|
||||
```
|
||||
|
||||
```
|
||||
# Sample Data:
|
||||
# no A records for delegated.daplie.me
|
||||
# two external NS records for delegted.daplie.me
|
||||
# zone daplie.me exists
|
||||
# no A records for out-delegated.example.com
|
||||
# two external NS records for delegted.example.com
|
||||
# zone example.com exists
|
||||
|
||||
# Test:
|
||||
# should return NS records in AUTHORITY section, nothing else
|
||||
node bin/dig.js @localhost -p 65053 A delegated.daplie.me
|
||||
node bin/dig.js @localhost -p 65053 A out-delegated.example.com
|
||||
node bin/dig.js @localhost -p 65053 ANY out-delegated.example.com
|
||||
|
||||
# should return SOA records in AUTHORITY section, nothing else
|
||||
node bin/dig.js @localhost -p 65053 A in-delegated.example.com
|
||||
node bin/dig.js @localhost -p 65053 ANY in-delegated.example.com
|
||||
|
||||
# should return NS records in ANSWER section, nothing else
|
||||
node bin/dig.js @localhost -p 65053 NS delegated.daplie.me
|
||||
node bin/dig.js @localhost -p 65053 NS out-delegated.example.com
|
||||
node bin/dig.js @localhost -p 65053 NS in-delegated.example.com
|
||||
|
||||
|
||||
# Sample Data:
|
||||
# two A records for daplie.me
|
||||
# two A records for example.com
|
||||
# no NS records
|
||||
|
||||
# Test:
|
||||
# should return A records in ANSWER section, nothing else
|
||||
node bin/dig.js @localhost -p 65053 A daplie.me
|
||||
node bin/dig.js @localhost -p 65053 A example.com
|
||||
|
||||
# should return SOA records in AUTHORITY section, nothing else
|
||||
node bin/dig.js @localhost -p 65053 A doesntexist.daplie.me
|
||||
node bin/dig.js @localhost -p 65053 NS doesntexist.daplie.me
|
||||
node bin/dig.js @localhost -p 65053 A doesntexist.example.com
|
||||
node bin/dig.js @localhost -p 65053 NS doesntexist.example.com
|
||||
|
||||
|
||||
# Sample Data:
|
||||
# two A records for a.daplie.me
|
||||
# two A records for a.example.com
|
||||
# has **internal** NS records
|
||||
|
||||
# Test:
|
||||
# should return A record in ANSWER section, nothing else
|
||||
node bin/dig.js @localhost -p 65053 A a.daplie.me
|
||||
node bin/dig.js @localhost -p 65053 A a.example.com
|
||||
|
||||
# should return SOA record in AUTHORITY section, nothing else
|
||||
node bin/dig.js @localhost -p 65053 A doesntexist.a.daplie.me
|
||||
node bin/dig.js @localhost -p 65053 A doesntexist.a.example.com
|
||||
|
||||
# should return NS records in ANSWER section, nothing else
|
||||
node bin/dig.js @localhost -p 65053 NS a.daplie.me
|
||||
node bin/dig.js @localhost -p 65053 NS a.example.com
|
||||
|
||||
```
|
||||
|
|
181
lib/dns-store.js
181
lib/dns-store.js
|
@ -210,10 +210,14 @@ function domainToSoa(db, domain) {
|
|||
};
|
||||
}
|
||||
|
||||
function getSoa(db, domain, results, cb) {
|
||||
function getSoa(db, domain, results, cb, answerSoa) {
|
||||
console.log('[DEV] getSoa entered');
|
||||
|
||||
results.authority.push(domainToSoa(db, domain));
|
||||
if (!answerSoa) {
|
||||
results.authority.push(domainToSoa(db, domain));
|
||||
} else {
|
||||
results.answer.push(domainToSoa(db, domain));
|
||||
}
|
||||
|
||||
cb(null, results);
|
||||
return;
|
||||
|
@ -270,11 +274,110 @@ module.exports.query = function (input, query, cb) {
|
|||
, question: [ query.question[0] ], answer: [], authority: [], additional: []
|
||||
};
|
||||
|
||||
return getRecords(db, qname, function (err, myRecords) {
|
||||
function getNsAndSoa(getNsAlso, answerSoa) {
|
||||
// If the query is www.foo.delegated.example.com
|
||||
// and we have been delegated delegated.example.com
|
||||
// and delegated.example.com exists
|
||||
// but foo.delegated.example.com does not exist
|
||||
// what's the best strategy for returning the record?
|
||||
//
|
||||
// What does PowerDNS do in these situations?
|
||||
// https://doc.powerdns.com/md/authoritative/backend-generic-mysql/
|
||||
|
||||
// How to optimize:
|
||||
// Assume that if a record is being requested, it probably exists
|
||||
// (someone has probably published it somewhere)
|
||||
// If the record doesn't exist, then see if any of the domains are managed
|
||||
// [ 'www.john.smithfam.net', 'john.smithfam.net', 'smithfam.net', 'net' ]
|
||||
// Then if one of those exists, return the SOA record with NXDOMAIN
|
||||
|
||||
var qarr = qname.split('.');
|
||||
var qnames = [];
|
||||
while (qarr.length) {
|
||||
qnames.push(qarr.join('.').toLowerCase());
|
||||
qarr.shift(); // first
|
||||
}
|
||||
|
||||
var myDomains = db.domains.filter(function (d) {
|
||||
return -1 !== qnames.indexOf(d.id.toLowerCase());
|
||||
});
|
||||
|
||||
// this should result in a REFUSED status
|
||||
if (!myDomains.length) {
|
||||
// REFUSED will have no records, so we could still recursion, if enabled
|
||||
results.header.rcode = REFUSED;
|
||||
cb(null, results);
|
||||
return;
|
||||
}
|
||||
|
||||
myDomains.sort(function (d1, d2) {
|
||||
if (d1.id.length > d2.id.length) {
|
||||
return -1;
|
||||
}
|
||||
if (d1.id.length < d2.id.length) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
//console.log('sorted domains', myDomains);
|
||||
|
||||
if (!getNsAlso) {
|
||||
return getSoa(db, myDomains[0], results, cb, answerSoa);
|
||||
}
|
||||
|
||||
return getNs(db, myDomains.slice(0), results, function (err, results) {
|
||||
//console.log('[DEV] getNs complete');
|
||||
|
||||
if (err) { cb(err, results); return; }
|
||||
|
||||
// has NS records (or SOA record if NS records match the server itself)
|
||||
if (results.authority.length) {
|
||||
console.log(results); cb(null, results); return;
|
||||
}
|
||||
|
||||
// myDomains was sorted such that the longest was first
|
||||
return getSoa(db, myDomains[0], results, cb);
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
if ('SOA' === query.question[0].typeName) {
|
||||
return getNsAndSoa(false, true);
|
||||
}
|
||||
|
||||
//console.log('[DEV] QUERY NAME', qname);
|
||||
return getRecords(db, qname, function (err, someRecords) {
|
||||
var myRecords;
|
||||
var nsRecords = [];
|
||||
|
||||
if (err) { cb(err); return; }
|
||||
|
||||
// filter out NS (delegation) records, unless that is what is intended
|
||||
someRecords = someRecords.filter(function (r) {
|
||||
// If it's not an NS record, it's a potential result
|
||||
if ('NS' !== r.type && 'NS' !== r.typeName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the query was for NS, it's a potential result
|
||||
if ('NS' === query.question[0].typeName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If it's a vanity NS, it's not a valid NS for lookup
|
||||
if (-1 !== db.primaryNameservers.indexOf(r.data.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
nsRecords.push(r);
|
||||
return false;
|
||||
});
|
||||
|
||||
// TODO should NS be returned as ANSWER or AUTHORITY in ANY?
|
||||
myRecords = someRecords;
|
||||
if (255 !== query.question[0].type && 'ANY' !== query.question[0].typeName) {
|
||||
myRecords = myRecords.filter(function (r) {
|
||||
|
||||
return ((r.type && r.type === query.question[0].type)
|
||||
|| (r.type && r.type === query.question[0].typeName)
|
||||
|| (r.typeName && r.typeName === query.question[0].typeName)
|
||||
|
@ -287,72 +390,22 @@ module.exports.query = function (input, query, cb) {
|
|||
results.answer.push(dbToResourceRecord(r));
|
||||
});
|
||||
results.header.rcode = NOERROR;
|
||||
console.log('[DEV] results', results);
|
||||
//console.log('[DEV] ANSWER results', results);
|
||||
cb(null, results);
|
||||
return;
|
||||
}
|
||||
else if (nsRecords.length) {
|
||||
nsRecords.forEach(function (r) {
|
||||
results.authority.push(dbToResourceRecord(r));
|
||||
});
|
||||
results.header.rcode = NOERROR;
|
||||
//console.log('[DEV] AUTHORITY results', results);
|
||||
cb(null, results);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!myRecords.length) {
|
||||
// If the query is www.foo.delegated.example.com
|
||||
// and we have been delegated delegated.example.com
|
||||
// and delegated.example.com exists
|
||||
// but foo.delegated.example.com does not exist
|
||||
// what's the best strategy for returning the record?
|
||||
//
|
||||
// What does PowerDNS do in these situations?
|
||||
// https://doc.powerdns.com/md/authoritative/backend-generic-mysql/
|
||||
|
||||
// How to optimize:
|
||||
// Assume that if a record is being requested, it probably exists
|
||||
// (someone has probably published it somewhere)
|
||||
// If the record doesn't exist, then see if any of the domains are managed
|
||||
// [ 'www.john.smithfam.net', 'john.smithfam.net', 'smithfam.net', 'net' ]
|
||||
// Then if one of those exists, return the SOA record with NXDOMAIN
|
||||
|
||||
var qarr = qname.split('.');
|
||||
var qnames = [];
|
||||
while (qarr.length) {
|
||||
qnames.push(qarr.join('.').toLowerCase());
|
||||
qarr.shift(); // first
|
||||
}
|
||||
|
||||
var myDomains = db.domains.filter(function (d) {
|
||||
return -1 !== qnames.indexOf(d.id.toLowerCase());
|
||||
});
|
||||
|
||||
// this should result in a REFUSED status
|
||||
if (!myDomains.length) {
|
||||
// REFUSED will have no records, so we could still recursion, if enabled
|
||||
results.header.rcode = REFUSED;
|
||||
cb(null, results);
|
||||
return;
|
||||
}
|
||||
|
||||
myDomains.sort(function (d1, d2) {
|
||||
if (d1.id.length > d2.id.length) {
|
||||
return -1;
|
||||
}
|
||||
if (d1.id.length < d2.id.length) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
console.log('sorted domains', myDomains);
|
||||
|
||||
return getNs(db, myDomains.slice(0), results, function (err, results) {
|
||||
console.log('[DEV] getNs complete');
|
||||
|
||||
if (err) { cb(err, results); return; }
|
||||
|
||||
// has NS records (or SOA record if NS records match the server itself)
|
||||
if (results.authority.length) {
|
||||
console.log(results); cb(null, results); return;
|
||||
}
|
||||
|
||||
// myDomains was sorted such that the longest was first
|
||||
getSoa(db, myDomains[0], results, cb);
|
||||
|
||||
});
|
||||
getNsAndSoa(true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
|
119
samples/db.js
119
samples/db.js
|
@ -1,69 +1,112 @@
|
|||
'use strict';
|
||||
|
||||
module.exports = {
|
||||
"primaryNameservers": [ 'localhost' ] // 'ns1.redirect-www.org'
|
||||
"primaryNameservers": [ 'localhost' ] // 'ns1.vanity-dns.org'
|
||||
, "domains": [
|
||||
{ "id": "daplie.me", "revokedAt": 0 }
|
||||
, { "id": "oneal.daplie.me", "revokedAt": 0 }
|
||||
, { "id": "aj.oneal.daplie.me", "revokedAt": 0, "vanityNs": [ 'ns1.daplie.me', 'ns2.daplie.me' ] }
|
||||
{ "id": "example.com", "revokedAt": 0 }
|
||||
, { "id": "smith.example.com", "revokedAt": 0 }
|
||||
, { "id": "in-delegated.example.com", "revokedAt": 0 }
|
||||
, { "id": "john.smith.example.com", "revokedAt": 0, "vanityNs": [ 'ns1.dns-server.net', 'ns2.dns-server.net' ] }
|
||||
// test and probably remove
|
||||
//, { "id": "out-delegated.example.com", "revokedAt": 0 }
|
||||
]
|
||||
, "records": [
|
||||
// zone daplie.me should be able to have some records on its own
|
||||
{ "zone": "daplie.me", "name": "daplie.me", "tld": "me", "sld": "daplie", "sub": ""
|
||||
, "type": "A", "address": "23.228.168.108", "aname": "tardigrade.devices.daplie.me" }
|
||||
// zone example.com should be able to have some records on its own
|
||||
{ "zone": "example.com", "name": "example.com", "tld": "com", "sld": "example", "sub": ""
|
||||
, "type": "A", "address": "1.2.3.4", "aname": "fido.devices.example.com" }
|
||||
, { "zone": "example.com", "name": "example.com", "tld": "com", "sld": "example", "sub": ""
|
||||
, "type": "MX", "priority": 10, "exchange": "mxa.example.org" }
|
||||
, { "zone": "example.com", "name": "example.com", "tld": "com", "sld": "example", "sub": ""
|
||||
, "type": "MX", "priority": 10, "exchange": "mxb.example.org" }
|
||||
, { "zone": "example.com", "name": "example.com", "tld": "com", "sld": "example", "sub": ""
|
||||
, "type": "SRV", "priority": 10, "weight": 20, "port": 65065, "target": "spot.devices.example.com" }
|
||||
, { "zone": "example.com", "name": "example.com", "tld": "com", "sld": "example", "sub": ""
|
||||
, "type": "TXT", "data": [ "foo bar baz" ] }
|
||||
, { "zone": "example.com", "name": "example.com", "tld": "com", "sld": "example", "sub": ""
|
||||
, "type": "TXT", "data": [ "foo", "bar", "baz" ] }
|
||||
|
||||
, { "zone": "daplie.me", "name": "www.daplie.me", "tld": "me", "sld": "daplie", "sub": "www"
|
||||
, "type": "A", "address": "23.228.168.108", "aname": "tardigrade.devices.daplie.me" }
|
||||
// A, CNAME, ANAME, MX, SRV, TXT
|
||||
, { "zone": "example.com", "name": "a.example.com", "tld": "com", "sld": "example", "sub": "a"
|
||||
, "type": "A", "address": "4.3.2.1" }
|
||||
, { "zone": "example.com", "name": "aaaa.example.com", "tld": "com", "sld": "example", "sub": "aaaa"
|
||||
, "type": "A", "address": "::1" }
|
||||
, { "zone": "example.com", "name": "aname.example.com", "tld": "com", "sld": "example", "sub": "aname"
|
||||
, "type": "A", "aname": "amazon.com" }
|
||||
, { "zone": "example.com", "name": "devname.example.com", "tld": "com", "sld": "example", "sub": "devname"
|
||||
, "type": "A", "address": "1.2.3.4", "aname": "fido.devices.example.com" }
|
||||
, { "zone": "example.com", "name": "cname.example.com", "tld": "com", "sld": "example", "sub": "cname"
|
||||
, "type": "CNAME", "data": "example.com" } // TODO should return additional
|
||||
, { "zone": "example.com", "name": "mx.example.com", "tld": "com", "sld": "example", "sub": "mx"
|
||||
, "type": "MX", "priority": 10, "exchange": "mxa.example.org" }
|
||||
, { "zone": "example.com", "name": "mx.example.com", "tld": "com", "sld": "example", "sub": "mx"
|
||||
, "type": "MX", "priority": 10, "exchange": "mxb.example.org" }
|
||||
, { "zone": "example.com", "name": "srv.example.com", "tld": "com", "sld": "example", "sub": "srv"
|
||||
, "type": "SRV", "priority": 10, "weight": 20, "port": 65065, "target": "spot.devices.example.com" }
|
||||
, { "zone": "example.com", "name": "txt.example.com", "tld": "com", "sld": "example", "sub": "txt"
|
||||
, "type": "TXT", "data": [ "foo bar baz" ] }
|
||||
, { "zone": "example.com", "name": "mtxt.example.com", "tld": "com", "sld": "example", "sub": "mtxt"
|
||||
, "type": "TXT", "data": [ "foo", "bar", "baz" ] }
|
||||
|
||||
, { "zone": "daplie.me", "name": "aname.daplie.me", "tld": "me", "sld": "daplie", "sub": "aname"
|
||||
, "type": "A", "aname": "google.com" }
|
||||
|
||||
, { "zone": "daplie.me", "name": "email.daplie.me", "tld": "me", "sld": "daplie", "sub": "email"
|
||||
// www., email., etc just for fun
|
||||
, { "zone": "example.com", "name": "www.example.com", "tld": "com", "sld": "example", "sub": "www"
|
||||
, "type": "A", "address": "1.2.3.4", "aname": "fido.devices.example.com" }
|
||||
, { "zone": "example.com", "name": "email.example.com", "tld": "com", "sld": "example", "sub": "email"
|
||||
, "type": "CNAME", "data": "mailgun.org" }
|
||||
|
||||
, { "zone": "daplie.me", "name": "tardigrade.devices.daplie.me", "tld": "me", "sld": "daplie", "sub": "tardigrade.devices"
|
||||
|
||||
// Out-delegated Domains
|
||||
, { "zone": "example.com", "type": "NS", "name": "out-delegated.example.com"
|
||||
, "tld": "com", "sld": "example", "sub": "out-delegated", "data": "ns1.vanity-dns.org" }
|
||||
, { "zone": "example.com", "type": "NS", "name": "out-delegated.example.com"
|
||||
, "tld": "com", "sld": "example", "sub": "out-delegated", "data": "ns2.vanity-dns.org" }
|
||||
|
||||
// In-delegated Domains
|
||||
, { "zone": "example.com", "type": "NS", "name": "in-delegated.example.com"
|
||||
, "tld": "com", "sld": "example", "sub": "in-delegated", "data": "localhost" }
|
||||
|
||||
, { "zone": "example.com", "name": "fido.devices.example.com", "tld": "com", "sld": "example", "sub": "fido.devices"
|
||||
, "device": "abcdef123"
|
||||
, "type": "ANAME", "address": "23.228.168.108" }
|
||||
, "type": "ANAME", "address": "1.2.3.4" }
|
||||
|
||||
// zone daplie.me can delegate oneal.daplie.me to the same nameserver
|
||||
// zone example.com can delegate smith.example.com to the same nameserver
|
||||
// (it's probably programmatically and politically simplest to always delegate from a parent zone)
|
||||
// Thought Experiment: could we delegate the root to a child? i.e. daplie.me -> www.daplie.me
|
||||
// Thought Experiment: could we delegate the root to a child? i.e. example.com -> www.example.com
|
||||
// to let someone exclusively "own" the root domain, but none of the children?
|
||||
, { "zone": "daplie.me", "type": "NS", "name": "oneal.daplie.me"
|
||||
, "tld": "me", "sld": "daplie", "sub": "oneal", "data": "ns1.redirect-www.org" }
|
||||
, { "zone": "example.com", "type": "NS", "name": "smith.example.com"
|
||||
, "tld": "com", "sld": "example", "sub": "smith", "data": "ns1.vanity-dns.org" }
|
||||
|
||||
, { "zone": "daplie.me", "name": "oneal.daplie.me", "tld": "me", "sld": "daplie", "sub": "oneal"
|
||||
, "type": "NS", "data": "ns2.redirect-www.org" }
|
||||
, { "zone": "example.com", "name": "smith.example.com", "tld": "com", "sld": "example", "sub": "smith"
|
||||
, "type": "NS", "data": "ns2.vanity-dns.org" }
|
||||
|
||||
//
|
||||
// now the zone "oneal.daplie.me" can be independently owned (and delegated)
|
||||
// ... but what about email for aj@daplie.me with aj@daplie.me?
|
||||
, { "zone": "oneal.daplie.me", "name": "oneal.daplie.me", "tld": "daplie.me", "sld": "oneal", "sub": ""
|
||||
, "type": "A", "address": "45.56.59.142", "aname": "leo.devices.oneal.daplie.me" }
|
||||
// now the zone "smith.example.com" can be independently owned (and delegated)
|
||||
// ... but what about email for john@example.com with john@example.com?
|
||||
, { "zone": "smith.example.com", "name": "smith.example.com", "tld": "example.com", "sld": "smith", "sub": ""
|
||||
, "type": "A", "address": "45.56.59.142", "aname": "rex.devices.smith.example.com" }
|
||||
|
||||
, { "zone": "oneal.daplie.me", "name": "www.oneal.daplie.me", "tld": "daplie.me", "sld": "oneal", "sub": "www"
|
||||
, "type": "CNAME", "data": "oneal.daplie.me" }
|
||||
, { "zone": "smith.example.com", "name": "www.smith.example.com", "tld": "example.com", "sld": "smith", "sub": "www"
|
||||
, "type": "CNAME", "data": "smith.example.com" }
|
||||
|
||||
, { "zone": "oneal.daplie.me", "name": "aj.oneal.daplie.me", "tld": "daplie.me", "sld": "oneal", "sub": "aj"
|
||||
, "type": "NS", "data": "ns1.redirect-www.org" }
|
||||
, { "zone": "smith.example.com", "name": "john.smith.example.com", "tld": "example.com", "sld": "smith", "sub": "john"
|
||||
, "type": "NS", "data": "ns1.vanity-dns.org" }
|
||||
|
||||
, { "zone": "oneal.daplie.me", "name": "aj.oneal.daplie.me", "tld": "daplie.me", "sld": "oneal", "sub": "aj"
|
||||
, "type": "NS", "data": "ns2.redirect-www.org" }
|
||||
, { "zone": "smith.example.com", "name": "john.smith.example.com", "tld": "example.com", "sld": "smith", "sub": "john"
|
||||
, "type": "NS", "data": "ns2.vanity-dns.org" }
|
||||
|
||||
// there can be a wildcard, to which a delegation is the exception
|
||||
, { "zone": "oneal.daplie.me", "name": "*.oneal.daplie.me", "tld": "daplie.me", "sld": "oneal", "sub": "*"
|
||||
, "type": "A", "address": "45.56.59.142", "aname": "leo.devices.oneal.daplie.me" }
|
||||
, { "zone": "smith.example.com", "name": "*.smith.example.com", "tld": "example.com", "sld": "smith", "sub": "*"
|
||||
, "type": "A", "address": "45.56.59.142", "aname": "rex.devices.smith.example.com" }
|
||||
|
||||
// there can be an exception to the delegation
|
||||
, { "zone": "oneal.daplie.me", "name": "exception.aj.oneal.daplie.me", "tld": "daplie.me", "sld": "oneal", "sub": "exception.aj"
|
||||
, "type": "A", "address": "45.56.59.142", "aname": "leo.devices.oneal.daplie.me" }
|
||||
, { "zone": "smith.example.com", "name": "exception.john.smith.example.com", "tld": "example.com", "sld": "smith", "sub": "exception.john"
|
||||
, "type": "A", "address": "45.56.59.142", "aname": "rex.devices.smith.example.com" }
|
||||
|
||||
|
||||
//
|
||||
// aj.oneal.daplie.me
|
||||
// john.smith.example.com
|
||||
//
|
||||
, { "zone": "aj.oneal.daplie.me", "name": "aj.oneal.daplie.me", "tld": "oneal.daplie.me", "sld": "aj", "sub": ""
|
||||
, "type": "A", "address": "45.56.59.142", "aname": "leo.devices.oneal.daplie.me" }
|
||||
, { "zone": "john.smith.example.com", "name": "john.smith.example.com", "tld": "smith.example.com", "sld": "john", "sub": ""
|
||||
, "type": "A", "address": "45.56.59.142", "aname": "rex.devices.smith.example.com" }
|
||||
]
|
||||
}
|
||||
;
|
||||
|
|
Loading…
Reference in New Issue