diff --git a/README.md b/README.md index 29b10df..9c33202 100644 --- a/README.md +++ b/README.md @@ -20,20 +20,21 @@ # Features +* 🧩 Supports authentication with password, NTLM hash, Kerberos ticket or PEM/PKCS#12 certificate * 🗒ī¸ Formats date/time, boolean and other categorical attributes into readable text * 😎 Pretty colors & cool emojis * 🔐 LDAPS & StartTLS support * ⏊ Fast explorer that loads objects on demand * 🔎 Recursive object search bundled with useful saved searches -* đŸ‘Ĩ Group members & user groups lookup +* đŸ‘Ĩ Flexible group members & user groups lookups * 🎡 Supports creation, editing and removal of objects and attributes * 🚙 Supports moving and renaming objects * 🗑ī¸ Supports searching deleted & recycled objects * 📁 Supports exporting specific subtrees of the directory into JSON files -* 📜 GPO Viewer -* 🌐 ADIDNS Viewer * 🕹ī¸ Interactive userAccountControl editor -* đŸ”Ĩ Interactive DACL editor +* đŸ”Ĩ Interactive DACL viewer + editor +* 🌐 Interactive ADIDNS viewer + editor (basic) +* 📜 GPO Viewer * đŸ§Ļ SOCKS support # Installation @@ -144,48 +145,52 @@ You can also change the address of your proxy using the `l` keybinding. ## Keybindings -| Keybinding | Context | Action | -| --------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------| -| Ctrl + Enter (or Ctrl + J) | Global | Next panel | -| f | Global | Toggle attribute formatting | -| e | Global | Toggle emojis | -| c | Global | Toggle colors | -| a | Global | Toggle attribute expansion for multi-value attributes | -| d | Global | Toggle "include deleted objects" flag | -| l | Global | Change current server address & credentials | -| Ctrl + r | Global | Reconnect to the server | -| Ctrl + u | Global | Upgrade connection to use TLS (with StartTLS) | -| Ctrl + f | Explorer & Search pages | Open the finder to search for cached objects & attributes with regex | -| Right Arrow | Explorer panel | Expand the children of the selected object | -| Left Arrow | Explorer panel | Collapse the children of the selected object | -| r | Explorer panel | Reload the attributes and children of the selected object | -| Ctrl + n | Explorer panel | Create a new object under the selected object | -| Ctrl + s | Explorer panel | Export all loaded nodes in the selected subtree into a JSON file | -| Ctrl + p | Explorer panel | Change the password of the selected user or computer account (requires TLS) | -| Ctrl + a | Explorer panel | Update the userAccountControl of the object interactively | -| Ctrl + l | Explorer panel | Move the selected object to another location | -| Delete | Explorer panel | Delete the selected object | -| r | Attributes panel | Reload the attributes for the selected object | -| Ctrl + e | Attributes panel | Edit the selected attribute of the selected object | -| Ctrl + n | Attributes panel | Create a new attribute in the selected object | -| Delete | Attributes panel | Delete the selected attribute of the selected object | -| Enter | Attributes panel (entries hidden) | Expand all hidden entries of an attribute | -| Delete | Groups panels | Remove the selected member from the searched group or vice-versa | -| Ctrl + s | Object groups panel | Export the current groups into a JSON file | -| Ctrl + s | Group members panel | Export the current group members into a JSON file | -| Ctrl + g | Groups panels / Explorer panel / Obj. Search panel | Add a member to the selected group / add the selected object into a group | -| Ctrl + d | Groups panels / Explorer panel / Obj. Search panel | Inspect the DACL of the currently selected object | -| Ctrl + o | DACL page | Change the owner of the current security descriptor | -| Ctrl + k | DACL page | Change the control flags of the current security descriptor | -| Ctrl + s | DACL page | Export the current security descriptor into a JSON file | -| Ctrl + n | DACL entries panel | Create a new ACE in the current DACL | -| Ctrl + e | DACL entries panel | Edit the selected ACE of the current DACL | -| Delete | DACL entries panel | Deletes the selected ACE of the current DACL | -| Ctrl + s | GPO page | Export the current GPOs and their links into a JSON file | -| Ctrl + s | DNS zones panel | Export the selected zones and their child DNS nodes into a JSON file | -| r | DNS zones panel | Reload the nodes of the selected zone / the records of the selected node | -| h | Global | Show/hide headers | -| q | Global | Exit the program | +| Keybinding | Context | Action | +| --------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------| +| Ctrl + Enter (or Ctrl + J) | Global | Next panel | +| f | Global | Toggle attribute formatting | +| e | Global | Toggle emojis | +| c | Global | Toggle colors | +| a | Global | Toggle attribute expansion for multi-value attributes | +| d | Global | Toggle "include deleted objects" flag | +| l | Global | Change current server address & credentials | +| Ctrl + r | Global | Reconnect to the server | +| Ctrl + u | Global | Upgrade connection to use TLS (with StartTLS) | +| Ctrl + f | Explorer & Search pages | Open the finder to search for cached objects & attributes with regex | +| Right Arrow | Explorer panel | Expand the children of the selected object | +| Left Arrow | Explorer panel | Collapse the children of the selected object | +| r | Explorer panel | Reload the attributes and children of the selected object | +| Ctrl + n | Explorer panel | Create a new object under the selected object | +| Ctrl + s | Explorer panel | Export all loaded nodes in the selected subtree into a JSON file | +| Ctrl + p | Explorer panel | Change the password of the selected user or computer account (requires TLS) | +| Ctrl + a | Explorer panel | Update the userAccountControl of the object interactively | +| Ctrl + l | Explorer panel | Move the selected object to another location | +| Delete | Explorer panel | Delete the selected object | +| r | Attributes panel | Reload the attributes for the selected object | +| Ctrl + e | Attributes panel | Edit the selected attribute of the selected object | +| Ctrl + n | Attributes panel | Create a new attribute in the selected object | +| Delete | Attributes panel | Delete the selected attribute of the selected object | +| Enter | Attributes panel (entries hidden) | Expand all hidden entries of an attribute | +| Delete | Groups panels | Remove the selected member from the searched group or vice-versa | +| Ctrl + s | Object groups panel | Export the current groups into a JSON file | +| Ctrl + s | Group members panel | Export the current group members into a JSON file | +| Ctrl + g | Groups panels / Explorer panel / Obj. Search panel | Add a member to the selected group / add the selected object into a group | +| Ctrl + d | Groups panels / Explorer panel / Obj. Search panel | Inspect the DACL of the currently selected object | +| Ctrl + o | DACL page | Change the owner of the current security descriptor | +| Ctrl + k | DACL page | Change the control flags of the current security descriptor | +| Ctrl + s | DACL page | Export the current security descriptor into a JSON file | +| Ctrl + n | DACL entries panel | Create a new ACE in the current DACL | +| Ctrl + e | DACL entries panel | Edit the selected ACE of the current DACL | +| Delete | DACL entries panel | Deletes the selected ACE of the current DACL | +| Ctrl + s | GPO page | Export the current GPOs and their links into a JSON file | +| Ctrl + s | DNS zones panel | Export the selected zones and their child DNS nodes into a JSON file | +| r | DNS zones panel | Reload the nodes of the selected zone / the records of the selected node | +| Ctrl + n | DNS zones panel | Create a new node under the selected zone or a new zone if the root is selected | +| Ctrl + e | DNS zones panel | Edit the records of the currently selected node | +| Delete | DNS zones panel | Delete the selected DNS zone or DNS node | +| Delete | Records Preview (in `ADIDNS Node Editor`) | Delete the selected record of the ADIDNS node | +| h | Global | Show/hide headers | +| q | Global | Exit the program | ## Tree Colors diff --git a/TODO.md b/TODO.md index 1b81f84..3f9fd4c 100644 --- a/TODO.md +++ b/TODO.md @@ -1,14 +1,16 @@ # TODO (priority) -* Feature: Basic page for ADCS enumeration * Feature: Search history for the current session * Feature: Pivot to groups search * Feature: Options to manipulate (edit/create/delete) gpLinks visually -* Feature: Modify ADIDNS dnsZones and dnsNodes * Fix: Warn user of wrong KRB5CCNAME formats +* Feature: Basic page for ADCS enumeration # TODO (later) +* Wish: Remove dependency on personal fork of gokrb5 (may be doable with go-ldap's PR537) +* Wish: Remove dependency on personal fork of go-ldap (may be doable with go-ldap's PR537) +* Feature: Modify ADIDNS zone properties * Feature: Improve object creation form (implement customizations) * Feature: Custom themes * Feature: Customizable keybindings @@ -16,6 +18,4 @@ * Wish: Add tests for core functions to make sure everything is in order * Wish: Mini tool to convert godap exports into bloodhound dumps * Wish: Monitor object for real-time changes (DirSync/SyncRepl) -* Wish: Some way to copy data from panels (not implemented in tview, only for the "textarea" primitive) -* Wish: Remove dependency on personal fork of gokrb5 -* Wish: Remove dependency on personal fork of go-ldap +* Wish: Some way to copy data from panels (not implemented in tview, only for the "textarea" primitive) \ No newline at end of file diff --git a/pkg/adidns/types.go b/pkg/adidns/types.go index 678ebc9..939150b 100644 --- a/pkg/adidns/types.go +++ b/pkg/adidns/types.go @@ -8,6 +8,7 @@ import ( "io/ioutil" "net" "reflect" + "strconv" "strings" "time" ) @@ -135,7 +136,7 @@ type DNSRecord struct { Data []byte } -func MakeDNSRecord(rec FriendlyRecord, recType uint16, ttl uint32) DNSRecord { +func MakeDNSRecord(rec RecordData, recType uint16, ttl uint32) DNSRecord { serial := uint32(1) msTime := GetCurrentMSTime() data := rec.Encode() @@ -400,6 +401,99 @@ func (d *DNSRecord) PrintType() string { return recordType } +func (r *DNSRecord) GetRecordData() RecordData { + var parsedRecord RecordData + + switch r.Type { + case 0x0000: + parsedRecord = new(ZERORecord) + case 0x0001: + parsedRecord = new(ARecord) + case 0x0002: + parsedRecord = new(NSRecord) + case 0x0003: + parsedRecord = new(MDRecord) + case 0x0004: + parsedRecord = new(MFRecord) + case 0x0005: + parsedRecord = new(CNAMERecord) + case 0x0006: + parsedRecord = new(SOARecord) + case 0x0007: + parsedRecord = new(MBRecord) + case 0x0008: + parsedRecord = new(MGRecord) + case 0x0009: + parsedRecord = new(MRRecord) + case 0x000A: + parsedRecord = new(NULLRecord) + case 0x000B: + parsedRecord = new(WKSRecord) + case 0x000C: + parsedRecord = new(PTRRecord) + case 0x000D: + parsedRecord = new(HINFORecord) + case 0x000E: + parsedRecord = new(MINFORecord) + case 0x000F: + parsedRecord = new(MXRecord) + case 0x0010: + parsedRecord = new(TXTRecord) + case 0x0011: + parsedRecord = new(RPRecord) + case 0x0012: + parsedRecord = new(AFSDBRecord) + case 0x0013: + parsedRecord = new(X25Record) + case 0x0014: + parsedRecord = new(ISDNRecord) + case 0x0015: + parsedRecord = new(RTRecord) + case 0x0018: + parsedRecord = new(SIGRecord) + case 0x0019: + parsedRecord = new(KEYRecord) + case 0x001C: + parsedRecord = new(AAAARecord) + case 0x001D: + parsedRecord = new(LOCRecord) + case 0x001E: + parsedRecord = new(NXTRecord) + case 0x0021: + parsedRecord = new(SRVRecord) + case 0x0022: + parsedRecord = new(ATMARecord) + case 0x0023: + parsedRecord = new(NAPTRRecord) + case 0x0027: + parsedRecord = new(DNAMERecord) + case 0x002B: + parsedRecord = new(DSRecord) + case 0x002E: + parsedRecord = new(RRSIGRecord) + case 0x002F: + parsedRecord = new(NSECRecord) + case 0x0030: + parsedRecord = new(DNSKEYRecord) + case 0x0031: + parsedRecord = new(DHCIDRecord) + case 0x0032: + parsedRecord = new(NSEC3Record) + case 0x0033: + parsedRecord = new(NSEC3PARAMRecord) + case 0x0034: + parsedRecord = new(TLSARecord) + case 0xFF01: + parsedRecord = new(WINSRecord) + case 0xFF02: + parsedRecord = new(WINSRRecord) + default: + parsedRecord = new(ZERORecord) + } + parsedRecord.Parse(r.Data) + return parsedRecord +} + func (d *DNSRecord) UnixTimestamp() int64 { msTime := uint64(d.Timestamp) * 3600 return MSTimeToUnixTimestamp(msTime) @@ -563,7 +657,7 @@ func (p *DNSProperty) Decode(data []byte) error { // IP addresses (v4 or v6) are stored using their string representations // Interface to hold the parsed record fields -type FriendlyRecord interface { +type RecordData interface { // Parses a record from its byte array in the Data field of the // DNSRecord AD attribute Parse([]byte) @@ -581,7 +675,7 @@ type Field struct { Value any } -func DumpRecordFields(fr FriendlyRecord) []Field { +func DumpRecordFields(fr RecordData) []Field { result := make([]Field, 0) v := reflect.ValueOf(fr).Elem() @@ -1350,3 +1444,101 @@ func (r *WINSRRecord) Encode() []byte { return buf.Bytes() } + +func parseUint(value string) uint64 { + parsed, _ := strconv.ParseUint(value, 10, 64) + return parsed +} + +func RecordFromInput(recordType string, recordValue any) RecordData { + var fRecord RecordData + + switch recordType { + case "ZERO": + fRecord = new(ZERORecord) + case "A": + fRecord = new(ARecord) + fRecord.(*ARecord).Address = recordValue.(string) + case "AAAA": + fRecord = new(AAAARecord) + fRecord.(*AAAARecord).Address = recordValue.(string) + case "CNAME": + fRecord = new(CNAMERecord) + fRecord.(*CNAMERecord).NameNode = recordValue.(string) + case "TXT": + fRecord = new(TXTRecord) + fRecord.(*TXTRecord).StrData = recordValue.([]string) + case "NS": + fRecord = new(NSRecord) + fRecord.(*NSRecord).NameNode = recordValue.(string) + case "PTR": + fRecord = new(PTRRecord) + fRecord.(*PTRRecord).NameNode = recordValue.(string) + case "MD": + fRecord = new(MDRecord) + fRecord.(*MDRecord).NameNode = recordValue.(string) + case "MF": + fRecord = new(MFRecord) + fRecord.(*MFRecord).NameNode = recordValue.(string) + case "MB": + fRecord = new(MBRecord) + fRecord.(*MBRecord).NameNode = recordValue.(string) + case "MG": + fRecord = new(MGRecord) + fRecord.(*MGRecord).NameNode = recordValue.(string) + case "MR": + fRecord = new(MRRecord) + fRecord.(*MRRecord).NameNode = recordValue.(string) + case "DNAME": + fRecord = new(DNAMERecord) + fRecord.(*DNAMERecord).NameNode = recordValue.(string) + case "HINFO": + fRecord = new(HINFORecord) + fRecord.(*HINFORecord).StrData = recordValue.([]string) + case "ISDN": + fRecord = new(ISDNRecord) + fRecord.(*ISDNRecord).StrData = recordValue.([]string) + case "X25": + fRecord = new(X25Record) + fRecord.(*X25Record).StrData = recordValue.([]string) + case "LOC": + fRecord = new(LOCRecord) + fRecord.(*LOCRecord).StrData = recordValue.([]string) + case "MX": + valMap := recordValue.(map[string]string) + fRecord = new(MXRecord) + fRecord.(*MXRecord).Preference = uint16(parseUint(valMap["Preference"])) + fRecord.(*MXRecord).Exchange = valMap["Exchange"] + case "AFSDB": + valMap := recordValue.(map[string]string) + fRecord = new(AFSDBRecord) + fRecord.(*AFSDBRecord).Preference = uint16(parseUint(valMap["Preference"])) + fRecord.(*AFSDBRecord).Exchange = valMap["Exchange"] + case "RT": + valMap := recordValue.(map[string]string) + fRecord = new(RTRecord) + fRecord.(*RTRecord).Preference = uint16(parseUint(valMap["Preference"])) + fRecord.(*RTRecord).Exchange = valMap["Exchange"] + case "SRV": + valMap := recordValue.(map[string]string) + fRecord = new(SRVRecord) + fRecord.(*SRVRecord).Priority = uint16(parseUint(valMap["Priority"])) + fRecord.(*SRVRecord).Weight = uint16(parseUint(valMap["Weight"])) + fRecord.(*SRVRecord).Port = uint16(parseUint(valMap["Port"])) + fRecord.(*SRVRecord).NameTarget = valMap["NameTarget"] + case "SOA": + valMap := recordValue.(map[string]string) + fRecord = new(SOARecord) + fRecord.(*SOARecord).Serial = uint32(parseUint(valMap["Serial"])) + fRecord.(*SOARecord).Refresh = uint32(parseUint(valMap["Refresh"])) + fRecord.(*SOARecord).Retry = uint32(parseUint(valMap["Retry"])) + fRecord.(*SOARecord).Expire = uint32(parseUint(valMap["Expire"])) + fRecord.(*SOARecord).MinimumTTL = uint32(parseUint(valMap["MinimumTTL"])) + fRecord.(*SOARecord).NamePrimaryServer = valMap["NamePrimaryServer"] + fRecord.(*SOARecord).ZoneAdminEmail = valMap["ZoneAdminEmail"] + default: + fRecord = new(ZERORecord) + } + + return fRecord +} diff --git a/tui/dns.go b/tui/dns.go index 565bb46..8264b56 100644 --- a/tui/dns.go +++ b/tui/dns.go @@ -43,11 +43,14 @@ var forestZones []adidns.DNSZone var zoneCache = make(map[string]adidns.DNSZone, 0) var nodeCache = make(map[string]adidns.DNSNode, 0) -var recordCache = make(map[string][]adidns.FriendlyRecord, 0) - func getParentZone(objectDN string) (adidns.DNSZone, error) { objectDNParts := strings.Split(objectDN, ",") + zone, zoneOk := zoneCache[objectDN] + if zoneOk { + return zone, nil + } + if len(objectDNParts) > 1 { parentZoneDN := strings.Join(objectDNParts[1:], ",") parentZone, zoneOk := zoneCache[parentZoneDN] @@ -58,7 +61,7 @@ func getParentZone(objectDN string) (adidns.DNSZone, error) { } } - return adidns.DNSZone{}, fmt.Errorf("Object DN too small to contain a parent zone") + return adidns.DNSZone{}, fmt.Errorf("Malformed object DN") } func exportADIDNSToFile(currentNode *tview.TreeNode, outputFilename string) { @@ -89,11 +92,11 @@ func exportADIDNSToFile(currentNode *tview.TreeNode, outputFilename string) { "Nodes": nodesMap, } } else if nodeOk { - records, _ := recordCache[objectDN] + records := node.Records recordsObj := make([]any, 0) - for idx, rec := range records { - recordType := node.Records[idx].PrintType() + for _, rec := range records { + recordType := rec.PrintType() recordsObj = append(recordsObj, map[string]any{ "Type": recordType, "Value": rec, @@ -228,11 +231,10 @@ func reloadADIDNSNode(currentNode *tview.TreeNode) { updateLog(fmt.Sprint(err), "red") } - storeNodeRecords(node) showDetails(node.DN) } -func showNodeDetails(node *adidns.DNSNode, records []adidns.FriendlyRecord, targetTree *tview.TreeView) { +func showDNSNodeDetails(node *adidns.DNSNode, targetTree *tview.TreeView) { rootNode := tview.NewTreeNode(node.Name) for idx, record := range node.Records { @@ -275,7 +277,7 @@ func showNodeDetails(node *adidns.DNSNode, records []adidns.FriendlyRecord, targ recordTreeNode := tview.NewTreeNode(recordName). SetReference(recordRef{node.DN, idx}) - parsedRecord := records[idx] + parsedRecord := record.GetRecordData() recordFields := adidns.DumpRecordFields(parsedRecord) for idx, field := range recordFields { fieldName := tview.Escape(fmt.Sprintf("%s=%v", field.Name, field.Value)) @@ -300,7 +302,6 @@ func showDetails(objectDN string) { node, ok := nodeCache[objectDN] if ok { - parsedRecords, _ := recordCache[objectDN] parentZone, err := getParentZone(objectDN) if err == nil { dnsSidePanel.SetTitle(fmt.Sprintf("Records (%s)", parentZone.Name)) @@ -309,108 +310,8 @@ func showDetails(objectDN string) { } dnsSidePanel.SwitchToPage("node-records") - showNodeDetails(&node, parsedRecords, dnsNodeRecords) - } -} - -func storeNodeRecords(node adidns.DNSNode) { - records := make([]adidns.FriendlyRecord, 0) - var fRec adidns.FriendlyRecord - - for _, record := range node.Records { - switch record.Type { - case 0x0000: - fRec = new(adidns.ZERORecord) - case 0x0001: - fRec = new(adidns.ARecord) - case 0x0002: - fRec = new(adidns.NSRecord) - case 0x0003: - fRec = new(adidns.MDRecord) - case 0x0004: - fRec = new(adidns.MFRecord) - case 0x0005: - fRec = new(adidns.CNAMERecord) - case 0x0006: - fRec = new(adidns.SOARecord) - case 0x0007: - fRec = new(adidns.MBRecord) - case 0x0008: - fRec = new(adidns.MGRecord) - case 0x0009: - fRec = new(adidns.MRRecord) - case 0x000A: - fRec = new(adidns.NULLRecord) - case 0x000B: - fRec = new(adidns.WKSRecord) - case 0x000C: - fRec = new(adidns.PTRRecord) - case 0x000D: - fRec = new(adidns.HINFORecord) - case 0x000E: - fRec = new(adidns.MINFORecord) - case 0x000F: - fRec = new(adidns.MXRecord) - case 0x0010: - fRec = new(adidns.TXTRecord) - case 0x0011: - fRec = new(adidns.RPRecord) - case 0x0012: - fRec = new(adidns.AFSDBRecord) - case 0x0013: - fRec = new(adidns.X25Record) - case 0x0014: - fRec = new(adidns.ISDNRecord) - case 0x0015: - fRec = new(adidns.RTRecord) - case 0x0018: - fRec = new(adidns.SIGRecord) - case 0x0019: - fRec = new(adidns.KEYRecord) - case 0x001C: - fRec = new(adidns.AAAARecord) - case 0x001D: - fRec = new(adidns.LOCRecord) - case 0x001E: - fRec = new(adidns.NXTRecord) - case 0x0021: - fRec = new(adidns.SRVRecord) - case 0x0022: - fRec = new(adidns.ATMARecord) - case 0x0023: - fRec = new(adidns.NAPTRRecord) - case 0x0027: - fRec = new(adidns.DNAMERecord) - case 0x002B: - fRec = new(adidns.DSRecord) - case 0x002E: - fRec = new(adidns.RRSIGRecord) - case 0x002F: - fRec = new(adidns.NSECRecord) - case 0x0030: - fRec = new(adidns.DNSKEYRecord) - case 0x0031: - fRec = new(adidns.DHCIDRecord) - case 0x0032: - fRec = new(adidns.NSEC3Record) - case 0x0033: - fRec = new(adidns.NSEC3PARAMRecord) - case 0x0034: - fRec = new(adidns.TLSARecord) - case 0xFF01: - fRec = new(adidns.WINSRecord) - case 0xFF02: - fRec = new(adidns.WINSRRecord) - default: - continue - } - - fRec.Parse(record.Data) - - records = append(records, fRec) + showDNSNodeDetails(&node, dnsNodeRecords) } - - recordCache[node.DN] = records } func loadZoneNodes(zoneNode *tview.TreeNode) int { @@ -430,16 +331,16 @@ func loadZoneNodes(zoneNode *tview.TreeNode) int { zoneNode.ClearChildren() nodeFilter := dnsNodeFilter.GetText() - nodeRegexp, err := regexp.Compile(nodeFilter) + nodeRegexp, _ := regexp.Compile(nodeFilter) for _, node := range nodes { + nodeCache[node.DN] = node + nodeMatch := nodeRegexp.FindStringIndex(node.Name) if nodeMatch == nil { continue } - nodeCache[node.DN] = node - nodeName := node.Name if Emojis { nodeName = "📃" + nodeName @@ -451,7 +352,6 @@ func loadZoneNodes(zoneNode *tview.TreeNode) int { SetExpanded(false) zoneNode.AddChild(treeNode) - storeNodeRecords(node) } return len(nodes) @@ -560,7 +460,6 @@ func initADIDNSPage() { } openDeleteObjectForm(currentNode, func() { - level := currentNode.GetLevel() if level == 1 { go queryDnsZones(dnsQueryPanel.GetText()) } else if level == 2 { @@ -578,13 +477,50 @@ func initADIDNSPage() { outputFilename := fmt.Sprintf("%d_dns.json", unixTimestamp) exportADIDNSToFile(currentNode, outputFilename) case tcell.KeyCtrlN: - /* - TODO: Create zones or nodes - */ + if currentNode == dnsTreePanel.GetRoot() { + openCreateZoneForm() + } else { + if level == 1 { + openCreateNodeForm(currentNode) + } else if level == 2 { + parentZone := getParentNode(currentNode, dnsTreePanel) + openCreateNodeForm(parentZone) + } + } case tcell.KeyCtrlE: - /* - TODO: Edit node records or zone properties - */ + if level == 1 { + // TODO: Edit zone properties + } else if level == 2 { + openUpdateNodeForm(currentNode) + } + } + + return event + }) + + dnsNodeRecords.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + currentNode := dnsNodeRecords.GetCurrentNode() + if currentNode == nil || currentNode.GetReference() == nil { + return event + } + + switch event.Key() { + case tcell.KeyCtrlE: + node := dnsTreePanel.GetCurrentNode() + openUpdateNodeForm(node) + return nil + case tcell.KeyDelete: + if currentNode.GetLevel() == 1 { + openDeleteRecordForm(currentNode) + } else if currentNode.GetLevel() == 2 { + pathToCurrent := dnsNodeRecords.GetPath(currentNode) + if len(pathToCurrent) > 1 { + parentNode := pathToCurrent[len(pathToCurrent)-2] + openDeleteRecordForm(parentNode) + } + } + + return nil } return event @@ -710,7 +646,6 @@ func queryDnsZones(targetZone string) { clear(zoneCache) clear(domainZones) clear(forestZones) - clear(recordCache) app.QueueUpdateDraw(func() { updateLog("Querying ADIDNS zones...", "yellow") diff --git a/tui/dnsmodify.go b/tui/dnsmodify.go new file mode 100644 index 0000000..bd3a9f9 --- /dev/null +++ b/tui/dnsmodify.go @@ -0,0 +1,535 @@ +package tui + +import ( + "fmt" + "strconv" + "strings" + + "github.com/Macmod/godap/v2/pkg/adidns" + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +var supportedRecordTypes []string = []string{ + "A", "AAAA", "CNAME", "TXT", "NS", "SOA", "SRV", "MX", "PTR", + "MD", "MF", "MB", "MG", "MR", "DNAME", + "HINFO", "ISDN", "X25", "LOC", "AFSDB", "RT", +} + +func addZoneHandler(zoneForm *XForm, currentFocus tview.Primitive) func() { + return func() { + zoneName := zoneForm.GetFormItemByLabel("Zone Name").(*tview.InputField).GetText() + zoneAllowUpdate, _ := zoneForm.GetFormItemByLabel("Updates").(*tview.DropDown).GetCurrentOption() + zoneContainer, _ := zoneForm.GetFormItemByLabel("Container").(*tview.DropDown).GetCurrentOption() + zoneNS := zoneForm.GetFormItemByLabel("NameServer").(*tview.InputField).GetText() + zoneEmail := zoneForm.GetFormItemByLabel("AdminEmail").(*tview.InputField).GetText() + + propType := adidns.MakeProp(0x1, []byte{1, 0, 0, 0}) + propAllowUpdate := adidns.MakeProp(0x2, []byte{byte(zoneAllowUpdate)}) + propNoRefresh := adidns.MakeProp(0x10, []byte{168}) + propRefresh := adidns.MakeProp(0x20, []byte{168}) + propAging := adidns.MakeProp(0x40, []byte{0}) + propScavDa := adidns.MakeProp(0x90, []byte{}) + propAutoNsDa := adidns.MakeProp(0x92, []byte{}) + + defaultProps := []adidns.DNSProperty{ + propType, + propAllowUpdate, + propNoRefresh, + propRefresh, + propAging, + propScavDa, + propAutoNsDa, + } + + isForest := zoneContainer == 1 + zoneDN, err := lc.AddADIDNSZone(zoneName, defaultProps, isForest) + + if err != nil { + updateLog(fmt.Sprint(err), "red") + app.SetRoot(appPanel, true).SetFocus(currentFocus) + return + } + + // Basic records required so that + // the DNS will synchronize the zone from + // Active Directory + recSOA := adidns.MakeDNSRecord( + &adidns.SOARecord{1, 900, 600, 86400, 3600, zoneNS, zoneEmail}, + 0x06, + 3600, + ) + + recNS := adidns.MakeDNSRecord(&adidns.NSRecord{zoneNS}, 0x02, 3600) + + defaultRecords := []adidns.DNSRecord{ + recSOA, + recNS, + } + + _, err = lc.AddADIDNSNode("@", zoneDN, defaultRecords) + if err == nil { + updateLog(fmt.Sprintf("Zone '%s' created successfully!", zoneName), "green") + } else { + updateLog(fmt.Sprintf("Zone '%s' created without SOA & NS records - a problem might have occurred.", zoneName), "yellow") + } + + go queryDnsZones(dnsQueryPanel.GetText()) + app.SetRoot(appPanel, true).SetFocus(currentFocus) + } +} + +func openCreateZoneForm() { + currentFocus := app.GetFocus() + + zoneForm := NewXForm(). + AddInputField("Zone Name", "", 0, nil, nil). + AddDropDown("Container", []string{"DomainDnsZones", "ForestDnsZones"}, 0, nil). + AddDropDown("Updates", []string{"None", "Nonsecure and secure", "Secure only"}, 0, nil). + AddInputField("NameServer", "", 0, nil, nil). + AddInputField("AdminEmail", "", 0, nil, nil) + + zoneNameFormItem := zoneForm.GetFormItemByLabel("Zone Name").(*tview.InputField) + zoneNameFormItem.SetPlaceholder("example.com") + assignInputFieldTheme(zoneNameFormItem) + + zoneForm.SetInputCapture(handleEscape(dnsTreePanel)) + + zoneForm. + AddButton("Go Back", func() { + app.SetRoot(appPanel, true).SetFocus(currentFocus) + }). + AddButton("Add", addZoneHandler(zoneForm, currentFocus)) + + zoneForm.SetTitle("Create ADIDNS Zone").SetBorder(true) + app.SetRoot(zoneForm, true).SetFocus(zoneForm) +} + +func openActionNodeForm(target *tview.TreeNode, update bool) { + currentFocus := app.GetFocus() + + targetDN := target.GetReference().(string) + targetDNParts := strings.Split(targetDN, ",") + firstDNComponents := strings.Split(targetDNParts[0], "=") + firstDNValue := firstDNComponents[1] + + var ( + title string + ) + + if update { + title = "Update" + } else { + title = "Create" + } + + // Left panels + nodeInfoPanel := NewXForm() + nodeInfoPanel.SetTitle("Node") + nodeInfoPanel.SetBorder(true) + + recordValuePages := tview.NewPages() + + // Right panels + recordsPreview := tview.NewTreeView() + recordsPreview. + SetRoot(tview.NewTreeNode("")). + SetTitle("Records Preview"). + SetBorder(true) + + nodeNameInput := tview.NewInputField(). + SetLabel("Node Name"). + SetChangedFunc(func(text string) { + root := recordsPreview.GetRoot() + if root != nil { + root.SetText(text) + } + }) + nodeNameInput.SetPlaceholder("The node name is usually the subdomain you want to create") + assignInputFieldTheme(nodeNameInput) + + // Preview area internal structure + var stagedParsedRecords []adidns.RecordData + var stagedRecords []adidns.DNSRecord + + if update { + // Prefill the existing records + // of the node into the staging area + node, ok := nodeCache[targetDN] + if !ok { + return + } + existingRecords := node.Records + + stagedParsedRecords = make([]adidns.RecordData, len(existingRecords)) + stagedRecords = make([]adidns.DNSRecord, len(existingRecords)) + + copy(stagedRecords, existingRecords) + + for idx, record := range existingRecords { + parsedRecord := record.GetRecordData() + stagedParsedRecords[idx] = parsedRecord + } + + // Show the existing records in the preview + showDNSNodeDetails(&node, recordsPreview) + } else { + // Set up an empty staging area + stagedParsedRecords = make([]adidns.RecordData, 0) + stagedRecords = make([]adidns.DNSRecord, 0) + } + + recordsPreview.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyDelete: + currentNode := recordsPreview.GetCurrentNode() + if currentNode == nil { + return nil + } + + level := currentNode.GetLevel() + + var nodeToDelete *tview.TreeNode + + if level == 1 { + nodeToDelete = currentNode + } else if level == 2 { + pathToCurrent := recordsPreview.GetPath(currentNode) + if len(pathToCurrent) > 1 { + nodeToDelete = pathToCurrent[len(pathToCurrent)-2] + } + } + + if nodeToDelete != nil { + recIdx := -1 + siblings := recordsPreview.GetRoot().GetChildren() + for idx, node := range siblings { + if node == nodeToDelete { + recIdx = idx + } + } + + if recIdx != -1 { + stagedParsedRecords = append(stagedParsedRecords[:recIdx], stagedParsedRecords[recIdx+1:]...) + stagedRecords = append(stagedRecords[:recIdx], stagedRecords[recIdx+1:]...) + + recordsPreview.GetRoot().RemoveChild(nodeToDelete) + } + + go func() { + app.Draw() + }() + } + + return nil + } + + return event + }) + + // nodeInfoPanel setup + parentZone, err := getParentZone(targetDN) + if err == nil { + nodeInfoPanel.AddTextView("Zone DN", parentZone.DN, 0, 1, false, true) + } + if update { + nodeInfoPanel.AddTextView("Node Name", firstDNValue, 0, 1, false, true) + } + + // recordContent setup + recordTypeInput := tview.NewDropDown(). + SetLabel("Record Type"). + SetOptions(supportedRecordTypes, func(text string, index int) { + switch text { + case "HINFO", "ISDN", "TXT", "X25", "LOC": + recordValuePages.SwitchToPage("multiple") + case "MX", "AFSDB", "RT": + recordValuePages.SwitchToPage("namepref") + case "SRV": + recordValuePages.SwitchToPage("srv") + case "SOA": + recordValuePages.SwitchToPage("soa") + default: + recordValuePages.SwitchToPage("default") + } + }) + assignDropDownTheme(recordTypeInput) + + recordTypeInput. + SetCurrentOption(0). + SetLabelWidth(12). + SetBorderPadding(1, 0, 1, 1) + + recordTTLInput := tview.NewInputField().SetText("3600") + recordTTLInput. + SetLabel("Record TTL"). + SetLabelWidth(12). + SetBorderPadding(1, 0, 1, 1) + assignInputFieldTheme(recordTTLInput) + + nameprefRecordValueInput := NewXForm(). + AddInputField("Preference", "", 0, numericAcceptanceFunc, nil). + AddInputField("Exchange", "", 0, nil, nil) + + soaRecordValueInput := NewXForm(). + AddInputField("Serial", "", 0, numericAcceptanceFunc, nil). + AddInputField("Refresh", "", 0, numericAcceptanceFunc, nil). + AddInputField("Retry", "", 0, numericAcceptanceFunc, nil). + AddInputField("Expire", "", 0, numericAcceptanceFunc, nil). + AddInputField("MinimumTTL", "", 0, numericAcceptanceFunc, nil). + AddInputField("NamePrimaryServer", "", 0, nil, nil). + AddInputField("ZoneAdminEmail", "", 0, nil, nil) + + srvRecordValueInput := NewXForm(). + AddInputField("Priority", "", 0, numericAcceptanceFunc, nil). + AddInputField("Weight", "", 0, numericAcceptanceFunc, nil). + AddInputField("Port", "", 0, numericAcceptanceFunc, nil). + AddInputField("NameTarget", "", 0, nil, nil) + + defaultRecordValueInput := NewXForm(). + AddInputField("Record Value", "", 0, nil, nil) + defaultRecordValueInput.GetFormItem(0).(*tview.InputField).SetPlaceholder("Type the record value and add it to the preview") + + multipleRecordValueInput := NewXForm(). + AddTextArea("Record Values", "", 0, 0, 0, nil) + multipleRecordValueInput.GetFormItem(0).(*tview.TextArea).SetPlaceholder("Type in the values for the record line-by-line\nand add it to the preview") + + cancelBtn := tview.NewButton("Go Back").SetSelectedFunc(func() { + app.SetRoot(appPanel, true).SetFocus(currentFocus) + }) + assignButtonTheme(cancelBtn) + + updateBtn := tview.NewButton(title).SetSelectedFunc(func() { + var nodeDN string + var action string + var err error + if !update { + nodeName := nodeNameInput.GetText() + nodeDN, err = lc.AddADIDNSNode( + nodeName, + targetDN, + stagedRecords, + ) + action = "created" + } else { + nodeDN = targetDN + err = lc.ReplaceADIDNSRecords( + nodeDN, + stagedRecords, + ) + action = "updated" + } + + if err != nil { + updateLog(fmt.Sprint(err), "red") + app.SetRoot(appPanel, true).SetFocus(currentFocus) + return + } + + go app.QueueUpdateDraw(func() { + if !update { + reloadADIDNSZone(target) + } else { + reloadADIDNSNode(target) + } + }) + + updateLog(fmt.Sprintf("Node '%s' %s successfully", nodeDN, action), "green") + app.SetRoot(appPanel, true).SetFocus(currentFocus) + }) + assignButtonTheme(updateBtn) + + addToPreview := tview.NewButton("Add To Preview").SetSelectedFunc(func() { + _, recordTypeVal := recordTypeInput.GetCurrentOption() + recordTTLVal := recordTTLInput.GetText() + recordTTLInt, err := strconv.Atoi(recordTTLVal) + + if err != nil { + return + } + + // Append the new record to the preview area + var recordValue any + + switch recordTypeVal { + case "HINFO", "ISDN", "TXT", "X25", "LOC": + recordValue = strings.Split(multipleRecordValueInput.GetFormItem(0).(*tview.TextArea).GetText(), "\n") + case "MX", "AFSDB", "RT": + recordValue = map[string]string{ + "Preference": nameprefRecordValueInput.GetFormItemByLabel("Preference").(*tview.InputField).GetText(), + "Exchange": nameprefRecordValueInput.GetFormItemByLabel("Exchange").(*tview.InputField).GetText(), + } + case "SOA": + recordValue = map[string]string{ + "Serial": soaRecordValueInput.GetFormItemByLabel("Serial").(*tview.InputField).GetText(), + "Refresh": soaRecordValueInput.GetFormItemByLabel("Refresh").(*tview.InputField).GetText(), + "Retry": soaRecordValueInput.GetFormItemByLabel("Retry").(*tview.InputField).GetText(), + "Expire": soaRecordValueInput.GetFormItemByLabel("Expire").(*tview.InputField).GetText(), + "MinimumTTL": soaRecordValueInput.GetFormItemByLabel("MinimumTTL").(*tview.InputField).GetText(), + "NamePrimaryServer": soaRecordValueInput.GetFormItemByLabel("NamePrimaryServer").(*tview.InputField).GetText(), + "ZoneAdminEmail": soaRecordValueInput.GetFormItemByLabel("ZoneAdminEmail").(*tview.InputField).GetText(), + } + case "SRV": + recordValue = map[string]string{ + "Priority": srvRecordValueInput.GetFormItemByLabel("Priority").(*tview.InputField).GetText(), + "Weight": srvRecordValueInput.GetFormItemByLabel("Weight").(*tview.InputField).GetText(), + "Port": srvRecordValueInput.GetFormItemByLabel("Port").(*tview.InputField).GetText(), + "NameTarget": srvRecordValueInput.GetFormItemByLabel("NameTarget").(*tview.InputField).GetText(), + } + default: + recordValue = defaultRecordValueInput.GetFormItem(0).(*tview.InputField).GetText() + } + + record := adidns.RecordFromInput(recordTypeVal, recordValue) + stagedParsedRecords = append(stagedParsedRecords, record) + + recordTypeInt := adidns.FindRecordType(recordTypeVal) + recordToStore := adidns.MakeDNSRecord(record, recordTypeInt, uint32(recordTTLInt)) + stagedRecords = append(stagedRecords, recordToStore) + + // Make a new node to add to the preview + var newNode adidns.DNSNode + if update { + node, ok := nodeCache[targetDN] + if !ok { + return + } + newNode = adidns.DNSNode{targetDN, node.Name, stagedRecords} + } else { + nodeName := nodeNameInput.GetText() + newNode = adidns.DNSNode{"", nodeName, stagedRecords} + } + + // Show preview + showDNSNodeDetails(&newNode, recordsPreview) + }) + assignButtonTheme(addToPreview) + + // Page setup + actionNodePanel := tview.NewFlex() + actionNodePanel. + SetInputCapture(handleEscape(dnsTreePanel)). + SetTitle(fmt.Sprintf("%s ADIDNS Node", title)). + SetBorder(true) + + actionNodePanel.SetDirection(tview.FlexRow) + + recordValuePages.AddPage("default", defaultRecordValueInput, true, true) + recordValuePages.AddPage("multiple", multipleRecordValueInput, true, false) + recordValuePages.AddPage("namepref", nameprefRecordValueInput, true, false) + recordValuePages.AddPage("soa", soaRecordValueInput, true, false) + recordValuePages.AddPage("srv", srvRecordValueInput, true, false) + + recordContentPanel := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(recordTypeInput, 2, 0, false). + AddItem(recordTTLInput, 2, 0, false). + AddItem(recordValuePages, 0, 1, false). + AddItem(addToPreview, 1, 0, false) + + recordContentPanel. + SetTitle("Record Contents"). + SetBorder(true) + + leftPanel := tview.NewFlex().SetDirection(tview.FlexRow) + + // If it's node creation, + // show an input to specify the node name. + // Otherwise just keep it hidden. + if !update { + nodeInfoPanel.AddFormItem(nodeNameInput) + } + + actionNodePanel.AddItem( + tview.NewFlex(). + AddItem( + leftPanel. + AddItem(nodeInfoPanel, 7, 0, false). + AddItem(recordContentPanel, 0, 1, false), + 0, 1, false). + AddItem(recordsPreview, 0, 1, false), + 0, 1, false). + AddItem( + tview.NewFlex(). + AddItem(tview.NewBox(), 1, 0, false). // Spacing + AddItem(cancelBtn, 10, 0, false). + AddItem(tview.NewBox(), 0, 1, false). // Spacing + AddItem(updateBtn, 10, 0, false). + AddItem(tview.NewBox(), 1, 0, false), // Spacing + 1, 0, false) + + app.SetRoot(actionNodePanel, true).SetFocus(actionNodePanel) +} + +func openUpdateNodeForm(node *tview.TreeNode) { + openActionNodeForm(node, true) +} + +func openCreateNodeForm(zone *tview.TreeNode) { + openActionNodeForm(zone, false) +} + +func openDeleteRecordForm(record *tview.TreeNode) { + currentFocus := app.GetFocus() + recRef := record.GetReference().(recordRef) + + nodeDN := recRef.nodeDN + recIdx := recRef.idx + + node, ok := nodeCache[nodeDN] + if !ok { + return + } + records := node.Records + + confirmText := fmt.Sprintf("Do you really want to delete this record?\nRecordIdx: %d\nNode: %s", recIdx, nodeDN) + promptModal := tview.NewModal(). + SetText(confirmText). + AddButtons([]string{"No", "Yes"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + if buttonLabel == "Yes" { + // TODO: Add safety check for changes outside Godap + updateRecords := append(records[:recIdx], records[recIdx+1:]...) + + err = lc.ReplaceADIDNSRecords(nodeDN, updateRecords) + if err != nil { + updateLog(fmt.Sprint(err), "red") + app.SetRoot(appPanel, true).SetFocus(currentFocus) + return + } + + node := dnsTreePanel.GetCurrentNode() + reloadADIDNSNode(node) + + updateLog("Record deleted successfully", "green") + app.SetRoot(appPanel, true).SetFocus(currentFocus) + } else { + app.SetRoot(appPanel, true).SetFocus(currentFocus) + } + }) + + app.SetRoot(promptModal, true).SetFocus(promptModal) +} + +/* +Records can also be added instead of replaced with: + +``` + rec := recordFromInput(recordType, recordValue) + + recordsToAdd := []adidns.DNSRecord{ + adidns.MakeDNSRecord( + rec, + adidns.FindRecordType(recordType), + uint32(recordTTLInt)), + } + + err = lc.AddADIDNSRecords(nodeDN, recordsToAdd) + if err != nil { + updateLog(fmt.Sprint(err), "red") + app.SetRoot(appPanel, true).SetFocus(currentFocus) + return + } + + reloadADIDNSNode(node) +```` +*/ diff --git a/tui/explorer.go b/tui/explorer.go index 5811207..ef210e1 100644 --- a/tui/explorer.go +++ b/tui/explorer.go @@ -133,7 +133,7 @@ func collapseTreeNode(node *tview.TreeNode) { } func reloadParentNode(node *tview.TreeNode) *tview.TreeNode { - parent := getParentNode(node) + parent := getParentNode(node, treePanel) if parent != nil { unloadChildren(parent) @@ -151,28 +151,6 @@ func reloadExplorerAttrsPanel(node *tview.TreeNode, useCache bool) { reloadAttributesPanel(node, explorerAttrsPanel, useCache, &explorerCache) } -func getParentNode(node *tview.TreeNode) *tview.TreeNode { - pathToCurrent := treePanel.GetPath(node) - - if len(pathToCurrent) > 1 { - return pathToCurrent[len(pathToCurrent)-2] - } - - return nil -} - -func findEntryInChildren(dn string, parent *tview.TreeNode) int { - siblings := parent.GetChildren() - - for idx, loopNode := range siblings { - if loopNode.GetReference().(string) == dn { - return idx - } - } - - return -1 -} - func exportCacheToFile(currentNode *tview.TreeNode, cache *EntryCache, outputFilename string) { exportMap := make(map[string]*ldap.Entry) currentNode.Walk(func(node, parent *tview.TreeNode) bool { @@ -430,7 +408,7 @@ func treePanelKeyHandler(event *tcell.EventKey) *tcell.EventKey { return event } - parentNode := getParentNode(currentNode) + parentNode := getParentNode(currentNode, treePanel) baseDN := currentNode.GetReference().(string) switch event.Rune() { diff --git a/tui/help.go b/tui/help.go index 7f3bcc0..7ed6217 100644 --- a/tui/help.go +++ b/tui/help.go @@ -60,6 +60,10 @@ func initHelpPage() { {"Ctrl + s", "GPO page", "Export the current GPOs and their links into a JSON file"}, {"Ctrl + s", "DNS zones panel", "Export the selected zones and their child DNS nodes into a JSON file"}, {"r", "DNS zones panel", "Reload the nodes of the selected zone / the records of the selected node"}, + {"Ctrl + n", "DNS zones panel", "Create a new node under the selected zone or a new zone if the root is selected"}, + {"Ctrl + e", "DNS zones panel", "Edit the records of the currently selected node"}, + {"Delete", "DNS zones panel", "Delete the selected DNS zone or DNS node"}, + {"Delete", "Records Preview (in ADIDNS Node Editor)", "Delete the selected record of the ADIDNS node"}, {"h", "Global", "Show/hide headers"}, {"q", "Global", "Exit the program"}, } diff --git a/tui/interface.go b/tui/interface.go index 9f7722e..4060d39 100644 --- a/tui/interface.go +++ b/tui/interface.go @@ -1,6 +1,8 @@ package tui import ( + "strconv" + "github.com/gdamore/tcell/v2" "github.com/rivo/tview" ) @@ -14,3 +16,30 @@ func handleEscape(returnFocus tview.Primitive) func(*tcell.EventKey) *tcell.Even return event } } + +func getParentNode(node *tview.TreeNode, tree *tview.TreeView) *tview.TreeNode { + pathToCurrent := tree.GetPath(node) + + if len(pathToCurrent) > 1 { + return pathToCurrent[len(pathToCurrent)-2] + } + + return nil +} + +func findEntryInChildren(dn string, parent *tview.TreeNode) int { + siblings := parent.GetChildren() + + for idx, loopNode := range siblings { + if loopNode.GetReference().(string) == dn { + return idx + } + } + + return -1 +} + +func numericAcceptanceFunc(textToCheck string, lastChar rune) bool { + _, err := strconv.Atoi(textToCheck) + return err == nil +} diff --git a/tui/main.go b/tui/main.go index 2f0fa35..cde5c88 100644 --- a/tui/main.go +++ b/tui/main.go @@ -18,7 +18,7 @@ import ( "software.sslmate.com/src/go-pkcs12" ) -var GodapVer = "Godap v2.9.0" +var GodapVer = "Godap v2.10.0" var ( LdapServer string diff --git a/tui/theme.go b/tui/theme.go index 825ad6f..5567561 100644 --- a/tui/theme.go +++ b/tui/theme.go @@ -30,6 +30,7 @@ type GodapTheme struct { DisabledNodeColor tcell.Color } +// Theme definitions - controls the colors of all Godap pages var baseTheme tview.Theme = tview.Theme{ PrimitiveBackgroundColor: tcell.ColorBlack, ContrastBackgroundColor: tcell.ColorBlue, @@ -50,7 +51,7 @@ var DefaultTheme = GodapTheme{ // Input fields for main pages FieldBackgroundColor: tcell.ColorBlack, - PlaceholderStyle: tcell.Style{}.Foreground(tcell.ColorDefault).Background(tcell.ColorBlack), + PlaceholderStyle: tcell.Style{}.Foreground(tcell.ColorGray).Background(tcell.ColorBlack), PlaceholderTextColor: tcell.ColorGray, // Form buttons @@ -79,6 +80,10 @@ func assignButtonTheme(btn *tview.Button) { SetActivatedStyle(DefaultTheme.FormButtonActivatedStyle) } +func assignDropDownTheme(dropdown *tview.DropDown) { + dropdown.SetFieldBackgroundColor(DefaultTheme.FieldBackgroundColor) +} + func assignFormTheme(form *tview.Form) { form. SetButtonBackgroundColor(DefaultTheme.FormButtonBackgroundColor). @@ -121,6 +126,8 @@ func (f *XForm) AddInputField(label, value string, fieldWidth int, accept func(t inputField := tview.NewInputField() f.AddFormItem(inputField. SetFieldStyle(tcell.StyleDefault.Background(tcell.ColorWhite).Foreground(tcell.ColorBlack)). + SetPlaceholderStyle(DefaultTheme.PlaceholderStyle). + SetPlaceholderTextColor(DefaultTheme.PlaceholderTextColor). SetLabel(label). SetText(value). SetFieldWidth(fieldWidth). @@ -130,6 +137,33 @@ func (f *XForm) AddInputField(label, value string, fieldWidth int, accept func(t return f } +func (f *XForm) AddTextArea(label string, text string, fieldWidth, fieldHeight int, maxLength int, changed func(text string)) *XForm { + if fieldHeight == 0 { + fieldHeight = tview.DefaultFormFieldHeight + } + + textArea := tview.NewTextArea() + textArea. + SetLabel(label). + SetSize(fieldHeight, fieldWidth). + SetMaxLength(maxLength). + SetPlaceholderStyle(DefaultTheme.PlaceholderStyle) + + if text != "" { + textArea.SetText(text, true) + } + + if changed != nil { + textArea.SetChangedFunc(func() { + changed(textArea.GetText()) + }) + } + + f.AddFormItem(textArea) + + return f +} + func (f *XForm) AddPasswordField(label, value string, fieldWidth int, mask rune, changed func(text string)) *XForm { if mask == 0 { mask = '*'