diff --git a/dns.go b/dns.go index b63d284..b4a019f 100644 --- a/dns.go +++ b/dns.go @@ -8,27 +8,33 @@ import ( ) type DNSRecord struct { - ID string `json:".id"` - Disabled string - Dynamic string - Name string - TTL string - Type string - ParsedValue string // not from RouterOS, here to not having to have the value logics in frontend too. + ID string `json:".id"` + Disabled string + Dynamic string + Name string + TTL string + Type string + ParsedValue string // not from RouterOS, here to not having to have the value logics in frontend too. + MatchSubdomain string - Address string - CNAME string + routerosEntry DNSEntry } type DNSEntry struct { - ID string `json:".id"` - Disabled string `json:"disabled"` - Name string `json:"name"` - TTL string `json:"ttl"` - Type string `json:"type"` + 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"` + Address string `json:"address,omitempty"` + CNAME string `json:"cname,omitempty"` + ForwardTo string `json:"forward-to,omitempty"` + NS string `json:"ns,omitempty"` + Text string `json:"text,omitempty"` + + AddressList string `json:"address-list,omitempty"` + MatchSubdomain string `json:"match-subdomain,omitempty"` } type DomainPart struct { @@ -66,22 +72,52 @@ func SortDNSRecord(a, b DNSRecord) int { return 0 } -func (r DNSRecord) String() string { +func NewDNSRecord(e DNSEntry) (r DNSRecord) { + r.routerosEntry = e + + r.ID = e.ID + r.Disabled = e.Disabled + r.Name = e.Name + r.TTL = e.TTL + r.Type = e.Type + r.MatchSubdomain = e.MatchSubdomain + + // Some routeros instances doesn't provide "type" when record is of type "A" :'( + if r.Type == "" { + r.Type = "A" + } + + r.ParsedValue = r.String() + + return +} +func (r DNSRecord) String() string { // {{{ switch r.Type { case "A", "AAAA": - return r.Address + return r.routerosEntry.Address + case "CNAME": - return r.CNAME + return r.routerosEntry.CNAME + + case "FWD": + return r.routerosEntry.ForwardTo + + case "NS": + return r.routerosEntry.NS + + case "NXDOMAIN": + return r.routerosEntry.AddressList + + case "TXT": + return r.routerosEntry.Text } return fmt.Sprintf("Implement type '%s'", r.Type) -} - -func (r DNSRecord) Parts() int { +} // }}} +func (r DNSRecord) Parts() int { // {{{ return len(strings.Split(r.Name, ".")) -} - -func (r DNSRecord) Part(numParts int) string { +} // }}} +func (r DNSRecord) Part(numParts int) string { // {{{ splitName := strings.Split(r.Name, ".") slices.Reverse(splitName) @@ -95,27 +131,39 @@ func (r DNSRecord) Part(numParts int) string { slices.Reverse(parts) return strings.Join(parts, ".") -} - -func (r DNSRecord) NameReversed() string { +} // }}} +func (r DNSRecord) NameReversed() string { // {{{ parts := strings.Split(r.Name, ".") slices.Reverse(parts) return strings.Join(parts, ".") -} - -func (r DNSRecord) toDNSEntry() (e DNSEntry) { +} // }}} +func (r DNSRecord) toDNSEntry() (e DNSEntry) { // {{{ e.ID = r.ID e.Disabled = r.Disabled e.Name = r.Name e.TTL = r.TTL e.Type = r.Type + e.MatchSubdomain = r.MatchSubdomain - switch(r.Type) { + switch r.Type { case "A", "AAAA": e.Address = r.ParsedValue + case "CNAME": e.CNAME = r.ParsedValue + + case "FWD": + e.ForwardTo = r.ParsedValue + + case "NS": + e.NS = r.ParsedValue + + case "NXDOMAIN": + e.AddressList = r.ParsedValue + + case "TXT": + e.Text = r.ParsedValue } return -} +} // }}} diff --git a/main.go b/main.go index fc40787..2f7fbd1 100644 --- a/main.go +++ b/main.go @@ -26,7 +26,7 @@ var ( device RouterosDevice ) -func init() {// {{{ +func init() { // {{{ initLogger = slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{})) confDir, err := os.UserConfigDir() @@ -49,23 +49,26 @@ func init() {// {{{ } logger = initLogging(config) -}// }}} -func initLogging(config Config) (*slog.Logger) {// {{{ +} // }}} +func initLogging(config Config) *slog.Logger { // {{{ opts := slog.HandlerOptions{} if flagDebug { opts.Level = slog.LevelDebug + handler := slog.NewJSONHandler(os.Stdout, &opts) + return slog.New(handler) + } else { + handler := vlog.New( + os.Stdout, + opts, + config.Logging.LogDir, + config.Logging.URL, + "routeros-dns", + config.Logging.System, + config.Logging.Instance, + ) + return slog.New(handler) } - handler := vlog.New( - os.Stdout, - opts, - config.Logging.LogDir, - config.Logging.URL, - "routeros-dns", - config.Logging.System, - config.Logging.Instance, - ) - return slog.New(handler) -}// }}} +} // }}} func main() { initLogger.Info("application", "version", VERSION) diff --git a/routeros_device.go b/routeros_device.go index e394abd..1a907de 100644 --- a/routeros_device.go +++ b/routeros_device.go @@ -106,23 +106,25 @@ func (dev *RouterosDevice) GetIdentity() (identity string, err error) { // {{{ return } // }}} -func (dev *RouterosDevice) StaticDNSEntries() (entries []*DNSRecord, err error) { - entries = []*DNSRecord{} +func (dev *RouterosDevice) StaticDNSEntries() (records []DNSRecord, err error) { + records = []DNSRecord{} + var routerosEntries []DNSEntry var body []byte body, err = dev.query("GET", "/ip/dns/static", []byte{}) if err != nil { return } - err = json.Unmarshal(body, &entries) + err = json.Unmarshal(body, &routerosEntries) if err != nil { return } - for _, entry := range entries { - entry.ParsedValue = entry.String() + for _, entry := range routerosEntries { + records = append(records, NewDNSRecord(entry)) } + logger.Info("FOO", "entry", records) return } diff --git a/static/css/index.css b/static/css/index.css index c269876..f076a7c 100644 --- a/static/css/index.css +++ b/static/css/index.css @@ -28,10 +28,64 @@ html { cursor: pointer; } +label { + user-select: none; +} + body { font-family: sans-serif; font-size: 12pt; margin-left: 32px; + + /* Boxed folders are a settings for the user. */ + &.boxed-folders { + #records-tree { + .folder { + &>.label { + background-color: var(--label-background); + width: min-content; + padding: 4px 8px; + border-radius: 4px; + border: 1px solid var(--label-border); + margin-top: 8px; + } + + &>.subfolders { + margin-left: 18px; + /* Default 10px */ + padding-bottom: 8px; + min-height: 8px; + + &:empty { + border-left: unset; + } + } + + &>.records { + margin-left: 18px; + /* Default 10px */ + + &>img { + padding-left: 12px; + } + } + } + } + } +} + +input, +select, +button { + font-size: 1em; + padding: 4px 8px; +} + +#settings-icon { + position: absolute; + top: 16px; + right: 16px; + width: 32px; } #records-tree { @@ -72,16 +126,6 @@ body { cursor: pointer; user-select: none; - /* - background-color: var(--label-background); - width: min-content; - padding: 4px 8px; - border-radius: 4px; - border: 1px solid var(--label-border); - margin-top: 8px; - margin-bottom: 8px; - */ - img { height: 20px; margin-right: 6px; @@ -120,6 +164,17 @@ body { .fqdn { cursor: pointer; user-select: none; + display: flex; + + .subdomains { + display: none; + } + + &.match-subdomains { + .subdomains { + display: inline; + } + } .first-label { color: var(--label-first); @@ -160,7 +215,6 @@ body { } } - #record-dialog { display: grid; grid-template-columns: min-content 1fr; @@ -174,7 +228,15 @@ body { } -input, select, button { - font-size: 1em; - padding: 4px 8px; +#settings-dialog { + display: grid; + grid-template-columns: min-content 1fr; + grid-gap: 6px 8px; + align-items: center; + + .buttons { + grid-column: 1 / -1; + text-align: center; + margin-top: 8px; + } } diff --git a/static/images/icon_settings.svg b/static/images/icon_settings.svg new file mode 100644 index 0000000..41e9394 --- /dev/null +++ b/static/images/icon_settings.svg @@ -0,0 +1,67 @@ + + + + diff --git a/static/js/dns.mjs b/static/js/dns.mjs index 330e87f..a46a185 100644 --- a/static/js/dns.mjs +++ b/static/js/dns.mjs @@ -1,28 +1,56 @@ +import { MessageBus } from '@mbus' + export class Application { constructor(records) {// {{{ + window._mbus = new MessageBus() + this.settings = new Settings() this.records = this.parseRecords(records) - this.folders = this.createFolders() + this.topFolder = new Folder(this, 'root') + this.recordsTree = null + this.settingsIcon = null + + this.renderFolders() this.render() }// }}} parseRecords(recordsData) {// {{{ const records = recordsData.map(d => new Record(d)) return records }// }}} - createFolders() {// {{{ - this.records.sort(this.sortRecords) - const topFolder = new Folder(this, 'root') + // cleanFolders removes all records from all folders. + // renderFolders can then put moved records in the correct + // (or newly created) folders again when record names are updated. + cleanFolders(folder) {// {{{ + if (folder === undefined) + folder = this.topFolder + + folder.records = [] + folder.subfolders.forEach((folder, _label) => { + this.cleanFolders(folder) + }) + }// }}} + renderFolders() {// {{{ + this.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) - let currFolder = topFolder + // Start each record from the top and iterate through all its labels + // except the first one since that would be the actual record. + let currFolder = this.topFolder let accFolderLabels = [] for (const i in labels) { const label = labels[i] + // The accumulated name is used to create each folder as the record progresses. accFolderLabels.push(label) const accFolderName = accFolderLabels.map(v => v).reverse().join('.') + // A new folder is created only when it doesn't exist + // to be able to update them when necessary. let folder = currFolder.subfolders.get(label) if (folder === undefined) { folder = new Folder(this, accFolderName) @@ -30,12 +58,10 @@ export class Application { } currFolder = folder - // Add the record to the innermost folder } + // Add the record to the innermost folder currFolder.addRecord(rec) } - - return topFolder }// }}} sortFolders(a, b) {// {{{ const aLabels = a.labels().reverse() @@ -67,15 +93,31 @@ export class Application { return 0 }// }}} render() {// {{{ - const tree = document.getElementById('records-tree') - tree.replaceChildren() + if (this.recordsTree == null) { + this.recordsTree = document.createElement('div') + this.recordsTree.id = 'records-tree' + + this.settingsIcon = document.createElement('img') + this.settingsIcon.id = 'settings-icon' + this.settingsIcon.src = `/images/${_VERSION}/icon_settings.svg` + this.settingsIcon.addEventListener('click', () => new SettingsDialog(this).show()) + + document.body.appendChild(this.recordsTree) + document.body.appendChild(this.settingsIcon) + } + //this.recordsTree.replaceChildren() // Top root folder doesn't have to be shown. - const folders = Array.from(this.folders.subfolders.values()) + const folders = Array.from(this.topFolder.subfolders.values()) folders.sort(this.sortFolders) for (const folder of folders) - tree.append(folder.toElement()) + this.recordsTree.append(folder.toElement()) + + // 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')) }// }}} handlerTop(event) {// {{{ @@ -125,6 +167,17 @@ export class Application { }// }}} + handlerSettingsUpdated({ key, value }) {// {{{ + if (key == 'boxed_folders') { + this.setBoxedFolders(value) + } + }// }}} + setBoxedFolders(state) {// {{{ + if (state) + document.body.classList.add('boxed-folders') + else + document.body.classList.remove('boxed-folders') + }// }}} } class Folder { @@ -250,6 +303,9 @@ class Record { value() {// {{{ return this.data.ParsedValue }// }}} + matchSubdomain() {// {{{ + return this.data.MatchSubdomain === 'true' + }// }}} labels() {// {{{ return this.name().split('.') }// }}} @@ -263,6 +319,17 @@ class Record { new RecordDialog(this).show() }// }}} set(key, value) {// {{{ + if (key == 'Name') { + if (value.slice(0, 2) == '*.') { + this.data['Name'] = value.slice(2) + this.data['MatchSubdomain'] = 'true' + } else { + this.data['Name'] = value + this.data['MatchSubdomain'] = 'false' + } + return + } + this.data[key] = value }// }}} render() {// {{{ @@ -280,8 +347,9 @@ class Record { this.divSeparator.classList.add('separator') this.divFQDN.innerHTML = ` - ${this.labels()[0]} - ${this.labels().slice(1).join('.')} + *. + + ` this.divFQDN.addEventListener('click', event => { @@ -299,6 +367,17 @@ class Record { }) } + // FQDN is updated. + if (this.matchSubdomain()) + this.divFQDN.classList.add('match-subdomains') + else + this.divFQDN.classList.remove('match-subdomains') + + 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}` : '' + this.divType.innerText = this.type() this.divValue.innerText = this.value() @@ -315,6 +394,9 @@ class Record { alert(json.Error) return } + _app.cleanFolders() + _app.renderFolders() + _app.render() }) }// }}} } @@ -336,6 +418,10 @@ class RecordDialog { + + + +