diff --git a/routeros_device.go b/routeros_device.go index 4c65e25..50c19d1 100644 --- a/routeros_device.go +++ b/routeros_device.go @@ -58,7 +58,7 @@ func (dev *RouterosDevice) Init() { // {{{ // query sends a RouterOS REST API query and returns the unparsed body. func (dev RouterosDevice) query(method, path string, reqBody []byte) (body []byte, err error) { // {{{ - url := fmt.Sprintf("https://%s/rest%s", dev.Host, path) + url := fmt.Sprintf("https://%s:%d/rest%s", dev.Host, dev.Port, path) logger.Info("URL", "method", method, "url", url) var request *http.Request @@ -150,6 +150,19 @@ func (dev *RouterosDevice) UpdateDNSEntry(record DNSEntry) (entry DNSEntry, err err = json.Unmarshal(body, &entry) return }// }}} +func (dev *RouterosDevice) DeleteDNSEntry(id string) (err error) { + _, err = dev.query("DELETE", "/ip/dns/static/"+id, []byte{}) + if err != nil { + rosError := struct{ Detail string }{} + if jsonError := json.Unmarshal([]byte(err.Error()), &rosError); jsonError == nil { + logger.Error("routeros", "error", jsonError) + err = errors.New(rosError.Detail) + return + } + return + } + return +} /* // FillPeerDetails retrieves RouterOS resource ID, allowed-address and comment from the router diff --git a/static/css/index.css b/static/css/index.css index 6957ab1..ba6c2a1 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -2,9 +2,6 @@ --line-color: #ccc; --line-color-record: #eee; - --type-background: #ddd; - --type-foreground: #000; - --label-first: #800033; --label-rest: #666; @@ -12,6 +9,18 @@ --label-border: #ccc; --copy-color: #d48700; + + --header-line: #d0d0d0; + + --record-A: #89a02c; + --record-AAAA: #2f4858; + --record-CNAME: #f6ae2d; + --record-FWD: #f26419; + --record-TXT: #86bbd8; + --record-NXDOMAIN: #aa0000; + --record-other: #888; + + --record-hover: #fffff4; } html { @@ -97,6 +106,10 @@ button { cursor: pointer; } +#search { + margin-bottom: 16px; +} + #records-tree { white-space: nowrap; @@ -107,6 +120,10 @@ button { padding-left: 0px; } + &.no-domain > .label { + font-style: italic; + } + &.open { &>.label>img.open { display: block; @@ -129,7 +146,7 @@ button { &>.label { display: grid; - grid-template-columns: min-content min-content 1fr; + grid-template-columns: repeat(4, min-content); align-items: center; padding: 5px 0px; cursor: pointer; @@ -140,6 +157,18 @@ button { margin-right: 6px; display: none; } + + img.create { + display: none; + height: 16px; + margin-left: 8px; + } + + &:hover { + img.create { + display: block; + } + } } &>.subfolders { @@ -149,17 +178,51 @@ button { &>.records { padding-left: 30px; + padding-top: 8px; + padding-bottom: 8px; + margin-left: 10px; display: grid; - grid-template-columns: repeat(3, min-content) 1fr; - grid-gap: 4px 10px; + grid-template-columns: repeat(6, min-content); + width: min-content; align-items: center; border-left: 1px solid var(--line-color); - &>img { - display: block; - padding-left: 4px; + .header { + font-weight: bold; + font-size: 0.75em; + background: #eee; + padding: 4px 8px; + border-left: 1px solid var(--header-line); + border-top: 1px solid var(--header-line); + border-bottom: 1px solid var(--header-line); + + &:first-child { + grid-column: 1 / 3; + border-top-left-radius: 4px; + } + + &.last { + border-right: 1px solid var(--header-line); + border-top-right-radius: 4px; + } + } + + &>:not(.header) { + height: 40px; + } + + &>.record-icon { + border-left: 1px solid var(--header-line); + align-content: center; + padding-left: 8px; + border-bottom: 1px solid var(--header-line); + + img { + display: block; + padding-left: 4px; + } } .copy { @@ -174,6 +237,14 @@ button { cursor: pointer; user-select: none; display: flex; + padding: 0 16px 0 8px; + border-bottom: 1px solid var(--header-line); + height: 100%; + align-items: center; + + &.mouse-over { + background-color: var(--record-hover); + } &.created { * { @@ -198,34 +269,107 @@ button { .rest-label { color: var(--label-rest); + + &.no-domain { + font-style: italic; + } } } .type { - background-color: var(--type-background); - color: var(--type-foreground); - padding: 4px 8px; - border-radius: 4px; - margin-top: 2px; - margin-bottom: 2px; - width: min-content; - font-weight: bold; - font-size: 0.85em; + padding: 2px 8px; + border-bottom: 1px solid var(--header-line); + border-left: 1px solid var(--header-line); + align-content: center; + cursor: pointer; + + &.mouse-over { + background-color: var(--record-hover); + } + + div { + background-color: var(--record-other); + color: #fff; + + padding: 4px 8px; + border-radius: 4px; + width: min-content; + height: min-content; + font-weight: bold; + font-size: 0.85em; + } + + &.A div { + background-color: var(--record-A); + } + + &.AAAA div { + background-color: var(--record-AAAA); + } + + &.CNAME div { + background-color: var(--record-CNAME); + color: #000; + } + + &.FWD div { + background-color: var(--record-FWD); + } + + &.TXT div { + background-color: var(--record-TXT); + color: #000; + } + + &.NXDOMAIN div { + background-color: var(--record-NXDOMAIN); + } } .value { cursor: pointer; user-select: none; - } + border-left: 1px solid var(--header-line); + border-bottom: 1px solid var(--header-line); + padding: 0px 8px; + height: 100%; + align-content: center; - .separator { - grid-column: 1 / -1; - - &:not(:last-child) { - border-bottom: 1px solid var(--line-color-record); + &.mouse-over { + background-color: var(--record-hover); } } + .ttl { + cursor: pointer; + border-left: 1px solid var(--header-line); + border-bottom: 1px solid var(--header-line); + padding: 0px 8px; + height: 100%; + align-content: center; + + &.mouse-over { + background-color: var(--record-hover); + } + } + + .actions { + border-left: 1px solid var(--header-line); + border-right: 1px solid var(--header-line); + border-bottom: 1px solid var(--header-line); + padding: 0px 8px; + height: 100%; + align-content: center; + + &.mouse-over { + background-color: var(--record-hover); + } + + img { + display: block; + cursor: pointer; + } + } } } diff --git a/static/images/icon_delete.svg b/static/images/icon_delete.svg new file mode 100644 index 0000000..0bc82d4 --- /dev/null +++ b/static/images/icon_delete.svg @@ -0,0 +1,49 @@ + + + + + + + + trash-can-outline + + + diff --git a/static/js/dns.mjs b/static/js/dns.mjs index 431521c..8f9195d 100644 --- a/static/js/dns.mjs +++ b/static/js/dns.mjs @@ -8,10 +8,22 @@ export class Application { this.topFolder = new Folder(this, null, 'root') this.recordsTree = null this.settingsIcon = null + this.createIcon = null + + this.searchFor = '' this.renderFolders() this.render() }// }}} + filteredRecords() {// {{{ + if (this.searchFor === '') + return this.records + + const records = this.records.filter(r => { + return (r.name().includes(this.searchFor)) + }) + return records + }// }}} parseRecords(recordsData) {// {{{ const records = recordsData.map(d => new Record(d)) return records @@ -29,14 +41,24 @@ export class Application { this.cleanFolders(folder) }) }// }}} + deleteRecord(id) {// {{{ + const i = this.records.findIndex(rec => rec.id() == id) + this.records.splice(i, 1) + }// }}} renderFolders() {// {{{ - this.records.sort(this.sortRecords) + const records = this.filteredRecords() + records.sort(this.sortRecords) - - // rec: for example www.google.com - for (const rec of this.records) { - // com.google (reverse and remove wwww) - const labels = rec.labels().reverse().slice(0, -1) + for (const rec of records) { + // It felt wrong when records for the base domain (e.g. google.com) was put in the top level domain (.com). + // While technically correct, the first label (com) is grouped together with the second label (google). + // Labels are counted reversely since the top domain is most significant. + // + // The `if` here is the exception for records that only has the two first labels. It would otherwise just + // be an empty array of labels and thus discarded. + let labels = rec.labels().reverse().slice(0, -1) + if (rec.labels().length == 1) + labels = rec.labels() // Start each record from the top and iterate through all its labels // except the first one since that would be the actual record. @@ -93,10 +115,7 @@ export class Application { return 0 }// }}} render() {// {{{ - if (this.recordsTree == null) { - this.recordsTree = document.createElement('div') - this.recordsTree.id = 'records-tree' - + if (this.createIcon === null) { this.createIcon = document.createElement('img') this.createIcon.id = 'create-icon' this.createIcon.src = `/images/${_VERSION}/icon_create.svg` @@ -107,13 +126,36 @@ export class Application { this.settingsIcon.src = `/images/${_VERSION}/icon_settings.svg` this.settingsIcon.addEventListener('click', () => new SettingsDialog(this).show()) - document.body.appendChild(this.recordsTree) + const searchEl = document.createElement('div') + searchEl.id = 'search' + searchEl.innerHTML = ` + + + ` + this.searchField = searchEl.querySelector('.search-for') + this.searchField.addEventListener('keydown', event => { + if (event.key == 'Enter') + this.search() + }) + const search = searchEl.querySelector('button.search') + search.addEventListener('click', () => this.search()) + document.body.appendChild(this.settingsIcon) document.body.appendChild(this.createIcon) + document.body.appendChild(searchEl) document.body.addEventListener('keydown', event => this.handlerKeys(event)) } + // The recordstree is deleted when making a search and is therefore + // not created along with icons and search above. + if (this.recordsTree === null) { + this.recordsTree = document.createElement('div') + this.recordsTree.id = 'records-tree' + + document.body.appendChild(this.recordsTree) + } + // Top root folder doesn't have to be shown. const folders = Array.from(this.topFolder.subfolders.values()) folders.sort(this.sortFolders) @@ -121,11 +163,31 @@ export class Application { for (const folder of folders) this.recordsTree.append(folder.render()) + this.removeEmptyFolders() + // Subscribe to settings update since the elements they will change // exists now. _mbus.subscribe('settings_updated', event => this.handlerSettingsUpdated(event.detail)) this.setBoxedFolders(this.settings.get('boxed_folders')) + }// }}} + removeEmptyFolders(folder) {// {{{ + if (folder === undefined) + folder = this.topFolder + folder.subfolders.forEach((folder, _label) => { + this.removeEmptyFolders(folder) + }) + + // This is a leaf folder in the tree. + // It has to be removed from the parent as well, since that could be up for + // removal as well, all the way up the chain. + if (folder.subfolders.size === 0 && folder.records.length === 0) { + if (folder.parentFolder) { + folder.parentFolder.subfolders.delete(folder.labels()[0]) + } + + folder.div?.remove() + } }// }}} handlerKeys(event) {// {{{ let handled = true @@ -143,6 +205,10 @@ export class Application { new RecordDialog(new Record()).show() break + case 'f': + this.searchField.focus() + break + default: handled = false } @@ -163,6 +229,16 @@ export class Application { else document.body.classList.remove('boxed-folders') }// }}} + search() {// {{{ + this.searchFor = this.searchField.value.trim().toLowerCase() + this.recordsTree.remove() + this.topFolder = new Folder(this, null, 'root') + this.recordsTree = null + + this.cleanFolders() + this.renderFolders() + this.render() + }// }}} } class Folder { @@ -184,7 +260,18 @@ class Folder { return this.folderName.toLowerCase() }// }}} labels() {// {{{ - return this.name().split('.') + let labels = this.name().split('.') + + // It is very uncommon to see just the top level domain. + // We're much more used to google.com than com and then google. + // First level is therefore the two most significant labels concatenated. + if (labels.length > 1) { + labels.reverse() + labels = [`${labels[1]}.${labels[0]}`].concat(labels.slice(2)) + labels.reverse() + } + + return labels }// }}} addRecord(rec) {// {{{ this.records.push(rec) @@ -230,14 +317,27 @@ class Folder { ${firstLabel}${restLabels != '' ? '.' + restLabels : ''} +
-
+
+
FQDN
+
Type
+
Value
+
TTL
+
Actions
+
` this.divSubfolders = this.div.querySelector('.subfolders') this.divRecords = this.div.querySelector('.records') this.div.querySelector('.label').addEventListener('click', event => this.toggleFolder(event)) + this.div.querySelector('.label .create').addEventListener('click', event => this.createRecord(event)) + + if (this.name() == '_no.domain') { + console.log('wut') + this.div.classList.add('no-domain') + } } @@ -249,11 +349,26 @@ class Folder { this.divSubfolders.append(folder.render()) // Records are refreshed. + if (this.records.length == 0) + this.divRecords.style.display = 'none' + else + this.divRecords.style.display = '' + + // Remove old ones + for (const recdiv of this.divRecords.children) { + if (recdiv?.classList?.contains('fqdn')) { + const rec = this.records.find(r => r.id() == recdiv.dataset.record_id) + if (!rec) + this.divRecords.querySelectorAll(`[data-record_id="${recdiv.dataset.record_id}"]`) + .forEach(el => el.remove()) + } + } + for (const rec of Array.from(this.records)) this.divRecords.append(...rec.render()) // Open this folder automatically if it is a toplevel folder and the settings change to open. - _mbus.subscribe('settings_updated', ({ detail: { key, value }}) => { + _mbus.subscribe('settings_updated', ({ detail: { key, value } }) => { if (key !== 'toplevel_open') return @@ -263,6 +378,11 @@ class Folder { return this.div }// }}} + createRecord(event) {// {{{ + event.stopPropagation() + const record = new Record({ Name: this.name() }) + new RecordDialog(record).show() + }// }}} } class Record { @@ -274,7 +394,8 @@ class Record { this.divFQDN = null this.divType = null this.divValue = null - this.divSeparator = null + this.divTTL = null + this.divActions = null }// }}} id() {// {{{ @@ -302,7 +423,24 @@ class Record { return this.data.MatchSubdomain === 'true' }// }}} labels() {// {{{ - return this.name().split('.') + let labels = this.name().split('.') + + if (labels.length === 1) { + labels = [labels[0], '_no', 'domain'] + } + + // It is very uncommon to see just the top level domain. + // We're much more used to google.com than com and then google. + // First level is therefore the two most significant labels concatenated. + if (labels.length > 1) { + labels.reverse() + labels = [`${labels[1]}.${labels[0]}`].concat(labels.slice(2)) + labels.reverse() + } else { + console.log(this, labels) + } + + return labels }// }}} copy(el, text) {// {{{ @@ -318,6 +456,8 @@ class Record { if (value.slice(0, 2) == '*.') { this.data['Name'] = value.slice(2) this.data['MatchSubdomain'] = 'true' + } else if (value.slice(-11) == '._no.domain') { + this.data['Name'] = value.slice(0, -11) } else { this.data['Name'] = value this.data['MatchSubdomain'] = 'false' @@ -332,23 +472,39 @@ class Record { }// }}} render() {// {{{ if (this.divFQDN === null) { - this.imgIcon = document.createElement('img') + this.imgIcon = document.createElement('div') this.divFQDN = document.createElement('div') this.divType = document.createElement('div') this.divValue = document.createElement('div') - this.divSeparator = document.createElement('div') + this.divTTL = document.createElement('div') + this.divActions = document.createElement('div') + + this.imgIcon.innerHTML = `` + this.imgIcon.classList.add("record-icon"); + + [this.imgIcon, this.divFQDN, this.divType, this.divValue, this.divTTL, this.divActions] + .forEach(div => { + div.addEventListener('mouseenter', () => this.mouseEnter()) + div.addEventListener('mouseleave', () => this.mouseLeave()) + div.dataset.record_id = this.id() + }) + this.divType.classList.add(this.type()) - this.imgIcon.src = `/images/${_VERSION}/icon_record.svg` this.divFQDN.classList.add('fqdn') this.divType.classList.add('type') this.divValue.classList.add('value') - this.divSeparator.classList.add('separator') + this.divTTL.classList.add('ttl') + this.divActions.classList.add('actions') + this.divType.innerHTML = `
` this.divFQDN.innerHTML = ` *. ` + this.divActions.innerHTML = ` + + ` this.divFQDN.addEventListener('click', event => { if (event.shiftKey) @@ -356,13 +512,15 @@ class Record { else this.edit() }) - this.divValue.addEventListener('click', event => { if (event.shiftKey) this.copy(event.target.closest('.value'), this.value()) else this.edit() }) + this.divType.addEventListener('click', () => this.edit()) + this.divTTL.addEventListener('click', () => this.edit()) + this.divActions.querySelector('.delete').addEventListener('click', () => this.delete()) } // FQDN is updated. @@ -373,14 +531,37 @@ class Record { const fl = this.labels()[0] const rl = this.labels().slice(1).join('.') - this.divFQDN.querySelector('.first-label').innerText = fl - this.divFQDN.querySelector('.rest-label').innerText = rl != '' ? `.${rl}` : '' + const flEl = this.divFQDN.querySelector('.first-label') + const rlEl = this.divFQDN.querySelector('.rest-label') + flEl.innerText = fl + rlEl.innerText = rl != '' ? `.${rl}` : '' - this.divType.innerText = this.type() + if (rl == '_no.domain') + rlEl.classList.add('no-domain') + else + rlEl.classList.remove('no-domain') + + this.divType.querySelector('div').innerText = this.type() this.divValue.innerText = this.value() + this.divTTL.innerText = this.ttl() - return [this.imgIcon, this.divFQDN, this.divType, this.divValue, this.divSeparator] + return [this.imgIcon, this.divFQDN, this.divType, this.divValue, this.divTTL, this.divActions] }// }}} + mouseEnter() {// {{{ + this.divFQDN.classList.add('mouse-over') + this.divType.classList.add('mouse-over') + this.divValue.classList.add('mouse-over') + this.divTTL.classList.add('mouse-over') + this.divActions.classList.add('mouse-over') + }// }}} + mouseLeave() {// {{{ + this.divFQDN.classList.remove('mouse-over') + this.divType.classList.remove('mouse-over') + this.divValue.classList.remove('mouse-over') + this.divTTL.classList.remove('mouse-over') + this.divActions.classList.remove('mouse-over') + }// }}} + save() {// {{{ const created = (this.id() == '') @@ -418,6 +599,23 @@ class Record { } }) }// }}} + delete() {// {{{ + if (!confirm(`Are you sure you want to delete ${this.name()}?`)) + return + + fetch(`/record/delete/${this.id()}`) + .then(data => data.json()) + .then(json => { + if (!json.OK) { + alert(json.Error) + return + } + _app.deleteRecord(this.id()) + _app.cleanFolders() + _app.renderFolders() + _app.render() + }) + }// }}} openParentFolders(folder) {// {{{ if (folder === undefined) @@ -505,8 +703,8 @@ class RecordDialog { class Settings { constructor() {// {{{ this.settings = new Map([ - ['boxed_folders', true], - ['toplevel_open', true], + ['boxed_folders', false], + ['toplevel_open', false], ]) // Read any configured settings from local storage, but keeping default value diff --git a/webserver.go b/webserver.go index 56f75c8..fc916ec 100644 --- a/webserver.go +++ b/webserver.go @@ -7,6 +7,7 @@ import ( // Standard "embed" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -33,6 +34,7 @@ func registerWebserverHandlers() { http.HandleFunc("/", rootHandler) http.HandleFunc("/record/save", actionRecordSave) + http.HandleFunc("/record/delete/{id}", actionRecordDelete) } func startWebserver() { @@ -110,6 +112,30 @@ func actionRecordSave(w http.ResponseWriter, r *http.Request) { record = NewDNSRecord(entry) - j, _ := json.Marshal(struct{ OK bool; Record DNSRecord }{true, record}) + j, _ := json.Marshal(struct { + OK bool + Record DNSRecord + }{true, record}) + w.Write(j) +} + +func actionRecordDelete(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + + if id == "" { + httpError(w, errors.New("No ID provided")) + return + } + + err := device.DeleteDNSEntry(id) + if err != nil { + httpError(w, err) + logger.Error("webserver", "op", "record_delete", "error", err) + return + } + + j, _ := json.Marshal(struct { + OK bool + }{true}) w.Write(j) }