Records update

This commit is contained in:
Magnus Åhall 2026-02-25 07:49:28 +01:00
parent 97058d036d
commit 2ae93b6fd4
4 changed files with 136 additions and 191 deletions

142
dns.go
View file

@ -3,7 +3,6 @@ package main
import ( import (
// Standard // Standard
"fmt" "fmt"
"maps"
"slices" "slices"
"strings" "strings"
) )
@ -21,6 +20,17 @@ type DNSRecord struct {
CNAME string CNAME string
} }
type DNSEntry struct {
ID string `json:".id"`
Disabled string `json:"disabled"`
Name string `json:"name"`
TTL string `json:"ttl"`
Type string `json:"type"`
Address string `json:"address,omitempty"`
CNAME string `json:"cname,omitempty"`
}
type DomainPart struct { type DomainPart struct {
Record []DNSRecord Record []DNSRecord
Subparts map[string]*DomainPart `json:",omitempty"` Subparts map[string]*DomainPart `json:",omitempty"`
@ -93,125 +103,19 @@ func (r DNSRecord) NameReversed() string {
return strings.Join(parts, ".") return strings.Join(parts, ".")
} }
func BuildRecordsTree(records []DNSRecord) *DomainPart { func (r DNSRecord) toDNSEntry() (e DNSEntry) {
topPart := new(DomainPart) e.ID = r.ID
topPart.Subparts = make(map[string]*DomainPart) e.Disabled = r.Disabled
e.Name = r.Name
e.TTL = r.TTL
e.Type = r.Type
for _, record := range records { switch(r.Type) {
curPart := topPart case "A", "AAAA":
e.Address = r.ParsedValue
currentDomainNameSplit := strings.Split(record.Name, ".") case "CNAME":
slices.Reverse(currentDomainNameSplit) e.CNAME = r.ParsedValue
for _, part := range currentDomainNameSplit {
if nextDomainPart, found := curPart.Subparts[part]; !found {
newPart := new(DomainPart)
newPart.Record = []DNSRecord{record}
newPart.Subparts = make(map[string]*DomainPart)
curPart.Subparts[strings.ToLower(part)] = newPart
curPart = newPart
} else {
nextDomainPart.Record = append(nextDomainPart.Record, record)
curPart = nextDomainPart
}
}
} }
return topPart return
}
type HTMLElement struct {
Header bool
HTML string
}
func (dp *DomainPart) ToHTMLElements(parts []string) []HTMLElement {
var lines []HTMLElement
sortedParts := slices.Sorted(maps.Keys(dp.Subparts))
for _, part := range sortedParts {
subpart := dp.Subparts[part]
newParts := append(parts, part)
reversedParts := make([]string, len(newParts))
copy(reversedParts, newParts)
slices.Reverse(reversedParts)
fqdn := strings.Join(reversedParts, ".")
mostSpecificPart := reversedParts[0]
restPart := ""
if len(reversedParts) > 1 {
restPart = strings.Join(reversedParts[1:], ".")
mostSpecificPart += "."
}
if len(subpart.Subparts) != 0 {
topmost := ""
if len(newParts) == 1 {
topmost = "top-most"
}
html := fmt.Sprintf(`
<div class="top %s" data-top="%s" data-self="%s" style="padding-left: %dpx">
<img class="folder closed" src="/images/%s/icon_folder.svg">
<img class="folder open" src="/images/%s/icon_folder_open.svg">
<span>%s</span><span>%s</span>
</div>
<div class="type"></div>
<div class="value"></div>
`,
topmost, // .top-most
restPart, // data-top
fqdn, // data-self
(len(newParts)-1)*32, // margin-left
VERSION, // images/
VERSION, // images/
mostSpecificPart, // innerText
restPart,
)
lines = append(lines, HTMLElement{Header: true, HTML: html})
subLines := subpart.ToHTMLElements(newParts)
lines = append(lines, subLines...)
}
}
for _, part := range sortedParts {
subpart := dp.Subparts[part]
newParts := append(parts, part)
reversedParts := make([]string, len(newParts))
copy(reversedParts, newParts)
slices.Reverse(reversedParts)
//fqdn := strings.Join(reversedParts, ".")
mostSpecificPart := reversedParts[0]
restPart := ""
if len(reversedParts) > 1 {
restPart = strings.Join(reversedParts[1:], ".")
mostSpecificPart += "."
}
if len(subpart.Subparts) == 0 {
for _, rec := range subpart.Record {
html := fmt.Sprintf(
`
<div class="record" style="padding-left: %dpx" data-top="%s"><div><span>%s</span><span>%s</span></div></div>
<div class="type"><div>%s</div></div>
<div class="value">%s</div>
`,
(len(newParts)-1)*32,
restPart, // data-top
mostSpecificPart,
restPart,
rec.Type,
rec.String(),
)
lines = append(lines, HTMLElement{Header: false, HTML: html})
}
}
}
return lines
} }

View file

@ -6,6 +6,7 @@ import (
"crypto/tls" "crypto/tls"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -105,8 +106,8 @@ func (dev *RouterosDevice) GetIdentity() (identity string, err error) { // {{{
return return
} // }}} } // }}}
func (dev *RouterosDevice) StaticDNSEntries() (entries []DNSRecord, err error) { func (dev *RouterosDevice) StaticDNSEntries() (entries []*DNSRecord, err error) {
entries = []DNSRecord{} entries = []*DNSRecord{}
var body []byte var body []byte
body, err = dev.query("GET", "/ip/dns/static", []byte{}) body, err = dev.query("GET", "/ip/dns/static", []byte{})
@ -119,6 +120,26 @@ func (dev *RouterosDevice) StaticDNSEntries() (entries []DNSRecord, err error) {
return return
} }
for _, entry := range entries {
entry.ParsedValue = entry.String()
}
return
}
func (dev *RouterosDevice) UpdateDNSEntry(record DNSEntry) (err error) {
req, _ := json.Marshal(record)
_, err = dev.query("PATCH", "/ip/dns/static/"+record.ID, req)
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 return
} }

View file

@ -212,7 +212,7 @@ class Folder {
// Records are refreshed. // Records are refreshed.
this.divRecords.replaceChildren() this.divRecords.replaceChildren()
for (const rec of Array.from(this.records)) for (const rec of Array.from(this.records))
this.divRecords.append(...rec.toElements()) this.divRecords.append(...rec.render())
return this.div return this.div
}// }}} }// }}}
@ -228,6 +228,7 @@ class Record {
this.divValue = null this.divValue = null
this.divSeparator = null this.divSeparator = null
}// }}} }// }}}
id() {// {{{ id() {// {{{
return this.data['.id'] return this.data['.id']
}// }}} }// }}}
@ -247,18 +248,12 @@ class Record {
return this.data.Type.toUpperCase() return this.data.Type.toUpperCase()
}// }}} }// }}}
value() {// {{{ value() {// {{{
switch (this.type()) { return this.data.ParsedValue
case 'A':
case 'AAAA':
return this.data.Address
case 'CNAME':
return this.data.CNAME
}
}// }}} }// }}}
labels() {// {{{ labels() {// {{{
return this.name().split('.') return this.name().split('.')
}// }}} }// }}}
copy(el, text) {// {{{ copy(el, text) {// {{{
el.classList.add('copy') el.classList.add('copy')
navigator.clipboard.writeText(text) navigator.clipboard.writeText(text)
@ -267,24 +262,10 @@ class Record {
edit() {// {{{ edit() {// {{{
new RecordDialog(this).show() new RecordDialog(this).show()
}// }}} }// }}}
set(key, value) { set(key, value) {// {{{
if (key != 'Value') { this.data[key] = value
this.data[key] = value }// }}}
return render() {// {{{
}
switch(this.data.type.toUpperCase()) {
case 'A':
case 'AAAA':
this.data.Address = value
break
case 'CNAME':
this.data.CNAME = value
break
}
}
toElements() {// {{{
if (this.divFQDN === null) { if (this.divFQDN === null) {
this.imgIcon = document.createElement('img') this.imgIcon = document.createElement('img')
this.divFQDN = document.createElement('div') this.divFQDN = document.createElement('div')
@ -323,17 +304,30 @@ class Record {
return [this.imgIcon, this.divFQDN, this.divType, this.divValue, this.divSeparator] return [this.imgIcon, this.divFQDN, this.divType, this.divValue, this.divSeparator]
}// }}} }// }}}
save() {// {{{
fetch('/record/save', {
method: 'POST',
body: JSON.stringify(this.data),
})
.then(data => data.json())
.then(json => {
if (!json.OK) {
alert(json.Error)
return
}
})
}// }}}
} }
class RecordDialog { class RecordDialog {
constructor(record) { constructor(record) {// {{{
this.record = record this.record = record
} }// }}}
show() { show() {// {{{
const dlg = document.createElement('dialog') this.dlg = document.createElement('dialog')
dlg.id = "record-dialog" this.dlg.id = "record-dialog"
dlg.innerHTML = ` this.dlg.innerHTML = `
<div>Name</div> <div>Name</div>
<input type="text" class="name"> <input type="text" class="name">
@ -356,23 +350,33 @@ class RecordDialog {
</div> </div>
` `
dlg.querySelector('.name').value = this.record.name() this.dlg.querySelector('.name').value = this.record.name()
dlg.querySelector('.type').value = this.record.type() this.dlg.querySelector('.type').value = this.record.type()
dlg.querySelector('.value').value = this.record.value() this.dlg.querySelector('.value').value = this.record.value()
dlg.querySelector('.ttl').value = this.record.ttl() this.dlg.querySelector('.ttl').value = this.record.ttl();
dlg.querySelector('.save').addEventListener('click', ()=>this.save()) ['.name', '.type', '.value', '.ttl'].forEach(v =>
dlg.querySelector('.close').addEventListener('click', ()=>dlg.close()) this.dlg.querySelector(v).addEventListener('keydown', event => this.enterKeyHandler(event))
)
dlg.addEventListener('close', ()=>dlg.remove()) this.dlg.querySelector('.save').addEventListener('click', () => this.save())
document.body.appendChild(dlg) this.dlg.querySelector('.close').addEventListener('click', () => this.dlg.close())
dlg.showModal()
}
save() { this.dlg.addEventListener('close', () => this.dlg.remove())
this.record.set('Name', dlg.querySelector('.name').value) document.body.appendChild(this.dlg)
this.record.set('Type', dlg.querySelector('.type').value) this.dlg.showModal()
this.record.set('Value', dlg.querySelector('.value').value) }// }}}
this.record.set('TTL', dlg.querySelector('.ttl').value) enterKeyHandler(event) {// {{{
} if (event.key == "Enter")
this.save()
}// }}}
save() {// {{{
this.record.set('Name', this.dlg.querySelector('.name').value)
this.record.set('Type', this.dlg.querySelector('.type').value)
this.record.set('ParsedValue', this.dlg.querySelector('.value').value)
this.record.set('TTL', this.dlg.querySelector('.ttl').value)
this.record.render()
this.record.save()
this.dlg.close()
}// }}}
} }

View file

@ -6,12 +6,11 @@ import (
// Standard // Standard
"embed" "embed"
_ "encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"net/http" "net/http"
"os" "os"
"slices"
_ "html/template"
) )
var ( var (
@ -33,6 +32,7 @@ func registerWebserverHandlers() {
} }
http.HandleFunc("/", rootHandler) http.HandleFunc("/", rootHandler)
http.HandleFunc("/record/save", actionRecordSave)
} }
func startWebserver() { func startWebserver() {
@ -56,31 +56,14 @@ func rootHandler(w http.ResponseWriter, r *http.Request) {
return return
} }
var entries []DNSRecord var entries []*DNSRecord
entries, err = device.StaticDNSEntries() entries, err = device.StaticDNSEntries()
if err != nil { if err != nil {
w.Write([]byte(err.Error())) w.Write([]byte(err.Error()))
return return
} }
slices.SortFunc(entries, SortDNSRecord)
data["DNSRecords"] = entries data["DNSRecords"] = entries
/*
tree := BuildRecordsTree(entries)
htmlElements := tree.ToHTMLElements([]string{})
data["TreeData"] = tree
var html string
for _, el := range htmlElements {
html += el.HTML
}
data["Tree"] = template.HTML(html)
j, _ := json.Marshal(tree)
os.WriteFile("/tmp/tree.json", j, 0644)
*/
data["VERSION"] = VERSION data["VERSION"] = VERSION
page.Data = data page.Data = data
@ -95,3 +78,36 @@ func rootHandler(w http.ResponseWriter, r *http.Request) {
htmlEngine.StaticResource(w, r) htmlEngine.StaticResource(w, r)
} }
func httpError(w http.ResponseWriter, err error) {
resp := struct {
OK bool
Error string
}{
false,
err.Error(),
}
j, _ := json.Marshal(resp)
w.Write(j)
}
func actionRecordSave(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var record DNSRecord
err := json.Unmarshal(body, &record)
if err != nil {
httpError(w, err)
return
}
err = device.UpdateDNSEntry(record.toDNSEntry())
if err != nil {
httpError(w, err)
logger.Error("webserver", "op", "record_save", "error", err)
return
}
j, _ := json.Marshal(struct{ OK bool }{true})
w.Write(j)
}