From 519a389b01e18082f7ac1538293fde0151429fd9 Mon Sep 17 00:00:00 2001 From: Benjamin Zores Date: Mon, 3 Jan 2022 16:48:08 +0100 Subject: [PATCH] add capability to tune in Unbound DNS host overrides --- CHANGELOG.md | 5 + opnsense/dns.go | 370 +++++++++++++++++++++ opnsense/provider.go | 8 +- opnsense/resource_opn_dns_host_override.go | 199 +++++++++++ 4 files changed, 581 insertions(+), 1 deletion(-) create mode 100644 opnsense/dns.go create mode 100644 opnsense/resource_opn_dns_host_override.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 917072e..59bd00c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.3.0 (January 3rd, 2022) + +* refactor generic Session handling +* add UnboundDNS support + ## 0.2.1 (December 10th, 2021) * ignore case when comparing MAC identifiers diff --git a/opnsense/dns.go b/opnsense/dns.go new file mode 100644 index 0000000..e6d52eb --- /dev/null +++ b/opnsense/dns.go @@ -0,0 +1,370 @@ +package opnsense + +import ( + "fmt" + "github.com/antchfx/htmlquery" + "github.com/asmcos/requests" + "golang.org/x/net/html" + "strings" +) + +// DNSEntryStartingRow exposes the HTML row where static maps actually start from +const DNSEntryStartingRow = 2 + +const ( + // DNSHost refers to the HTML table field for DNS host entry creation/edition + DNSHost = "Host" + // DNSDomain refers to the HTML table field for DNS host entry creation/edition + DNSDomain = "Domain" + // DNSType refers to the HTML table field for DNS host entry creation/edition + DNSType = "Type" + // DNSValue refers to the HTML table field for DNS host entry creation/edition + DNSValue = "Value" + // DNSDescription refers to the HTML table field for DNS host entry creation/edition + DNSDescription = "Description" +) + +const ( + // DNSServiceURI is the WebUI service URI + DNSServiceURI = "/services_unbound_overrides.php" + // DNSServiceEditURI is the WebUI service edit URI + DNSServiceEditURI = "/services_unbound_host_edit.php" +) + +const ( + // ErrDNSNoEntries is thrown when no entry can be found + ErrDNSNoEntries = "unable to retrieve list of DNS host overrides" + // ErrDNSHostExists is thrown when an entry already exists for this host override + ErrDNSHostExists = "DNS override for this host already exists" + // ErrDNSNoSuchEntry is thrown if no host override entry can be found + ErrDNSNoSuchEntry = "host override entry doesn't exists" +) + +// DNSSession abstracts OPNSense UnboundDNS Overrides +type DNSSession struct { + OPN *OPNSession + Fields []string +} + +// DNSHostEntry abstracts a DNS Host override +type DNSHostEntry struct { + ID int + Type string + Host string + Domain string + IP string +} + +/////////////////////// +// Private Functions // +/////////////////////// + +// GetStaticFieldNames extracts the HTML page host overrides headers for creation/edition +func (s *DNSSession) GetStaticFieldNames(node *html.Node, start int) { + if len(s.Fields) > 0 { + // already filled-in, no need to go any further + return + } + + q := fmt.Sprintf(`//table[@class="table table-striped"]//tr[%d]`, start) + headers := htmlquery.FindOne(node, q) + s.Fields = []string{} + for child := headers.FirstChild; child != nil; child = child.NextSibling { + if child.Type == html.ElementNode { + content := strings.TrimSpace(htmlquery.InnerText(child)) + if len(content) > 0 { + s.Fields = append(s.Fields, content) + } + } + } +} + +// GetStaticMappingField extracts a given DNS host override entry from OPNsense DNS overrides web page +func (s *DNSSession) GetStaticMappingField(node *html.Node, f string) string { + res := "" + + // find the requested field index in HTML table + id := index(s.Fields, f) + 1 + if id == -1 { + return res + } + + // XPath query to find the associated HTML node + q := fmt.Sprintf(`//td[%d]//text()`, id) + values, err := htmlquery.QueryAll(node, q) + if err != nil { + return res + } + + // extract value + for _, v := range values { + if v.Type != html.TextNode { + continue + } + content := strings.TrimSpace(htmlquery.InnerText(v)) + res = res + content + } + + return res +} + +// GetAllHostEntries retrieves the list of all configured DNS host overrides +func (s *DNSSession) GetAllHostEntries() ([]DNSHostEntry, error) { + + entries := []DNSHostEntry{} + + // check for proper authentication + err := s.OPN.IsAuthenticated() + if err != nil { + return entries, err + } + + // read out the service page + dnsURI := fmt.Sprintf("%s%s", s.OPN.RootURI, DNSServiceURI) + resp, err := s.OPN.Session.Get(dnsURI) + if err != nil { + return entries, err + } + + // get HTML + page := strings.NewReader(resp.Text()) + doc, err := htmlquery.Parse(page) + if err != nil { + return entries, err + } + + // lookup for static fields types + s.GetStaticFieldNames(doc, DNSEntryStartingRow) + + // XPath query to find all table rows + q := fmt.Sprintf(`//table[@class="table table-striped"]//tr`) + rows, err := htmlquery.QueryAll(doc, q) + if err != nil { + return entries, err + } + + // retrieve all configured DNS host override entries + for i := DNSEntryStartingRow; i < len(rows); i++ { + r := rows[i] + e := DNSHostEntry{ + ID: i - DNSEntryStartingRow, + Type: s.GetStaticMappingField(r, DNSType), + Host: s.GetStaticMappingField(r, DNSHost), + Domain: s.GetStaticMappingField(r, DNSDomain), + IP: s.GetStaticMappingField(r, DNSValue), + } + entries = append(entries, e) + } + + return entries, nil +} + +// HostsMatch compares if 2 host entries are alike +func (s *DNSSession) HostsMatch(e1, e2 *DNSHostEntry) bool { + if (e1.Host == e2.Host) && (e1.Domain == e2.Domain) && (e1.Type == e2.Type) && (e1.IP == e2.IP) { + return true + } + return false +} + +// FindHostEntry retrieves all entries select the one that matches +func (s *DNSSession) FindHostEntry(h *DNSHostEntry) (*DNSHostEntry, error) { + + // retrieves existing host entries + entries, err := s.GetAllHostEntries() + if err != nil { + return nil, err + } + + // check if an entry exists + for _, e := range entries { + // we found it + if s.HostsMatch(h, &e) { + return &e, nil + } + } + + return nil, s.OPN.Error(ErrDNSNoSuchEntry) +} + +// FindHostEntryByID retrieves all entries select the one that matches the ID +func (s *DNSSession) FindHostEntryByID(id int) (*DNSHostEntry, error) { + + // retrieves existing host entries + entries, err := s.GetAllHostEntries() + if err != nil { + return nil, err + } + + // check if an entry exists + for _, e := range entries { + // we found it + if e.ID == id { + return &e, nil + } + } + + return nil, s.OPN.Error(ErrDNSNoSuchEntry) +} + +// Apply validates the configuration and reload DNS server +func (s *DNSSession) Apply(formName, formValue string) error { + // apply changes + data := requests.Datas{ + "apply": "Apply changes", + } + if formName != "" || formValue != "" { + data[formName] = formValue + } + + applyURI := fmt.Sprintf("%s%s", s.OPN.RootURI, DNSServiceURI) + _, err := s.OPN.Session.Post(applyURI, data) + if err != nil { + return err + } + return nil +} + +// CreateOrEdit creates or edit an host override entry +func (s *DNSSession) CreateOrEdit(e *DNSHostEntry) error { + + // get the edit page to retrieve form secret values + editURI := fmt.Sprintf("%s%s", s.OPN.RootURI, DNSServiceEditURI) + if e.ID != -1 { + editURI = fmt.Sprintf("%s&id=%d", editURI, e.ID) + } + resp, err := s.OPN.Session.Get(editURI) + if err != nil { + return err + } + + // get HTML + page := strings.NewReader(resp.Text()) + doc, err := htmlquery.Parse(page) + if err != nil { + return err + } + + // get form runtime values + q := fmt.Sprintf(`//div[@class="content-box"]//form//input`) + n := htmlquery.FindOne(doc, q) + formName := htmlquery.SelectAttr(n, "name") + formValue := htmlquery.SelectAttr(n, "value") + + // create a new DHCP entry + data := requests.Datas{ + formName: formValue, + "host": e.Host, + "domain": e.Domain, + "rr": e.Type, + "ip": e.IP, + "descr": "", + "Submit": "Save", + } + if e.ID != -1 { + data["id"] = fmt.Sprintf("%d", e.ID) + } + + resp, err = s.OPN.Session.Post(editURI, data) + if err != nil { + return err + } + + // apply changes + err = s.Apply(formName, formValue) + if err != nil { + return err + } + + return nil +} + +////////////////////// +// Public Functions // +////////////////////// + +// CreateHostOverride creates a new DNS host override entry +func (s *DNSSession) CreateHostOverride(h *DNSHostEntry) error { + + e, err := s.FindHostEntry(h) + + // check if the host override is not already registered + if e != nil { + return s.OPN.Error(ErrDNSHostExists) + } + + // create the mapping entry + h.ID = -1 + err = s.CreateOrEdit(h) + if err != nil { + return err + } + + return nil +} + +// ReadHostOverride retrieves DNS information for a specified host +func (s *DNSSession) ReadHostOverride(h *DNSHostEntry) error { + + // check if an entry exists + e, err := s.FindHostEntry(h) + if e == nil { + return err + } + + // assign values accordingly + h.ID = e.ID + + return nil +} + +// UpdateHostOverride modifies an already existing host override +func (s *DNSSession) UpdateHostOverride(h *DNSHostEntry) error { + + // check if an entry exists for this specific ID + e, err := s.FindHostEntryByID(h.ID) + if e == nil { + return err + } + + // update the mapping entry + h.ID = e.ID + err = s.CreateOrEdit(h) + if err != nil { + return err + } + + return nil +} + +// DeleteHostOverride destroy an existing DNS host entry +func (s *DNSSession) DeleteHostOverride(h *DNSHostEntry) error { + + // check if an entry exists + e, err := s.FindHostEntry(h) + if e == nil { + return err + } + + // get the DNS page to retrieve form secret values + dnsURI := fmt.Sprintf("%s%s", s.OPN.RootURI, DNSServiceURI) + + // destroy DNS host entry + data := requests.Datas{ + "id": fmt.Sprintf("%d", e.ID), + "act": "del", + } + + _, err = s.OPN.Session.Post(dnsURI, data) + if err != nil { + return err + } + + // apply changes + err = s.Apply("", "") + if err != nil { + return err + } + + return nil +} diff --git a/opnsense/provider.go b/opnsense/provider.go index d991275..cfd3a63 100644 --- a/opnsense/provider.go +++ b/opnsense/provider.go @@ -13,6 +13,7 @@ import ( type ProviderConfiguration struct { OPN *OPNSession DHCP *DHCPSession + DNS *DNSSession Mutex *sync.Mutex Cond *sync.Cond } @@ -45,7 +46,8 @@ func Provider() terraform.ResourceProvider { }, ResourcesMap: map[string]*schema.Resource{ - "opnsense_dhcp_static_map": resourceOpnDHCPStaticMap(), + "opnsense_dhcp_static_map": resourceOpnDHCPStaticMap(), + "opnsense_dns_host_override": resourceOpnDNSHostOverride(), }, ConfigureFunc: providerConfigure, @@ -68,9 +70,13 @@ func providerConfigure(d *schema.ResourceData) (interface{}, error) { var dhcp = DHCPSession{ OPN: &opn, } + var dns = DNSSession{ + OPN: &opn, + } var provider = ProviderConfiguration{ OPN: &opn, DHCP: &dhcp, + DNS: &dns, Mutex: &mut, Cond: sync.NewCond(&mut), } diff --git a/opnsense/resource_opn_dns_host_override.go b/opnsense/resource_opn_dns_host_override.go new file mode 100644 index 0000000..4d1a1aa --- /dev/null +++ b/opnsense/resource_opn_dns_host_override.go @@ -0,0 +1,199 @@ +package opnsense + +import ( + "fmt" + "regexp" + "strconv" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/helper/validation" +) + +const ( + // KeyDNSType corresponds to the associated resource schema key + KeyDNSType = "type" + // KeyDNSHost corresponds to the associated resource schema key + KeyDNSHost = "host" + // KeyDNSDomain corresponds to the associated resource schema key + KeyDNSDomain = "domain" + // KeyDNSIP corresponds to the associated resource schema key + KeyDNSIP = "ip" +) + +func resourceOpnDNSHostOverride() *schema.Resource { + return &schema.Resource{ + Create: resourceDNSHostOverrideCreate, + Read: resourceDNSHostOverrideRead, + Update: resourceDNSHostOverrideUpdate, + Delete: resourceDNSHostOverrideDelete, + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Schema: map[string]*schema.Schema{ + KeyDNSType: { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.All(validation.StringIsNotEmpty, validation.StringIsNotWhiteSpace), + }, + KeyDNSHost: { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.All(validation.StringIsNotEmpty, validation.StringIsNotWhiteSpace), + }, + KeyDNSDomain: { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.All(validation.StringIsNotEmpty, validation.StringIsNotWhiteSpace), + }, + KeyDNSIP: { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.IsIPAddress, + }, + }, + } +} + +var dnsRsID = regexp.MustCompile("([^/]+)/([^/]+)/([^/]+)/([^/]+)/([^/]+)") + +func parseDNSResourceID(resID string) (*DNSHostEntry, error) { + e := DNSHostEntry{} + + if !dnsRsID.MatchString(resID) { + return &e, fmt.Errorf("invalid resource format: %s. must be type/host/domain/ip/id", resID) + } + idMatch := dnsRsID.FindStringSubmatch(resID) + e.Type = idMatch[1] + e.Host = idMatch[2] + e.Domain = idMatch[3] + e.IP = idMatch[4] + e.ID, _ = strconv.Atoi(idMatch[5]) + + return &e, nil +} + +func dnsResourceID(e *DNSHostEntry) string { + return fmt.Sprintf("%s/%s/%s/%s/%d", e.Type, e.Host, e.Domain, e.IP, e.ID) +} + +func resourceDNSHostOverrideCreate(d *schema.ResourceData, meta interface{}) error { + pconf := meta.(*ProviderConfiguration) + dns := pconf.DNS + lock := pconf.Mutex + + lock.Lock() + + // create a new host override + e := DNSHostEntry{ + Type: d.Get(KeyDNSType).(string), + Host: d.Get(KeyDNSHost).(string), + Domain: d.Get(KeyDNSDomain).(string), + IP: d.Get(KeyDNSIP).(string), + } + + err := dns.CreateHostOverride(&e) + if err != nil { + lock.Unlock() + return err + } + + time.Sleep(100 * time.Millisecond) + + // set resource ID accordingly + d.SetId(dnsResourceID(&e)) + + // read out resource again + lock.Unlock() + err = resourceDNSHostOverrideRead(d, meta) + + return err +} + +func resourceDNSHostOverrideRead(d *schema.ResourceData, meta interface{}) error { + pconf := meta.(*ProviderConfiguration) + lock := pconf.Mutex + dns := pconf.DNS + + lock.Lock() + defer lock.Unlock() + + e, err := parseDNSResourceID(d.Id()) + if err != nil { + d.SetId("") + return err + } + + // read out DNS Host information + err = dns.ReadHostOverride(e) + if err != nil { + d.SetId("") + return err + } + + // set Terraform resource ID + d.SetId(dnsResourceID(e)) + + // set object params + d.Set(KeyDNSType, e.Type) + d.Set(KeyDNSHost, e.Host) + d.Set(KeyDNSDomain, e.Domain) + d.Set(KeyDNSIP, e.IP) + + return nil +} + +func resourceDNSHostOverrideUpdate(d *schema.ResourceData, meta interface{}) error { + pconf := meta.(*ProviderConfiguration) + lock := pconf.Mutex + dns := pconf.DNS + + lock.Lock() + + e, err := parseDNSResourceID(d.Id()) + if err != nil { + d.SetId("") + lock.Unlock() + return err + } + + // updated entry + e.IP = d.Get(KeyDNSIP).(string) + + err = dns.UpdateHostOverride(e) + if err != nil { + lock.Unlock() + return err + } + + time.Sleep(100 * time.Millisecond) + + // read out resource again + lock.Unlock() + err = resourceDNSHostOverrideRead(d, meta) + + return nil +} + +func resourceDNSHostOverrideDelete(d *schema.ResourceData, meta interface{}) error { + pconf := meta.(*ProviderConfiguration) + lock := pconf.Mutex + dns := pconf.DNS + + lock.Lock() + defer lock.Unlock() + + e, err := parseDNSResourceID(d.Id()) + if err != nil { + d.SetId("") + return err + } + + err = dns.DeleteHostOverride(e) + if err != nil { + return err + } + + return nil +}