add domain whois/dns support, refactor codebase

This commit is contained in:
2025-09-17 20:38:51 +02:00
parent 477bc242aa
commit 16fc344a68
29 changed files with 1396 additions and 867 deletions
+48
View File
@@ -0,0 +1,48 @@
package common
import (
"sync"
"time"
)
// cachedItem represents a generic item in the cache.
type cachedItem struct {
data any
time time.Time
}
// Cache provides a thread-safe, generic caching mechanism with a TTL.
type Cache struct {
store sync.Map
ttl time.Duration
}
// NewCache creates a new generic cache with the specified TTL.
func NewCache(ttl time.Duration) *Cache {
return &Cache{
ttl: ttl,
}
}
// Set adds a new entry to the cache.
func (c *Cache) Set(key any, data any) {
c.store.Store(key, cachedItem{
data: data,
time: time.Now(),
})
}
// Get retrieves an entry from the cache.
func (c *Cache) Get(key any) (any, bool) {
if item, ok := c.store.Load(key); ok {
cached := item.(cachedItem)
if time.Since(cached.time) < c.ttl {
return cached.data, true
}
c.store.Delete(key)
}
return nil, false
}
// Global cache with a 10-minute TTL.
var cache = NewCache(10 * time.Minute)
-271
View File
@@ -1,271 +0,0 @@
package internal
import (
"fmt"
"log"
"net"
"net/http"
"sort"
"strings"
"sync"
"time"
db "skidoodle/ipinfo/internal/db"
iputils "skidoodle/ipinfo/utils/iputils"
)
// DataStruct represents the structure of the IP data returned by the API.
type DataStruct struct {
IP *string `json:"ip"`
Hostname *string `json:"hostname"`
Org *string `json:"org"`
City *string `json:"city"`
Region *string `json:"region"`
Country *string `json:"country"`
Timezone *string `json:"timezone"`
Loc *string `json:"loc"`
}
// ASNDataResponse represents the structure of the ASN data returned by the API.
type ASNDataResponse struct {
Details Details `json:"details"`
Prefixes PrefixInfo `json:"prefixes"`
}
// Details represents the structure of the ASN details returned by the API.
type Details struct {
ASN uint `json:"asn"`
Name string `json:"name"`
}
// PrefixInfo represents the structure of the ASN prefix information returned by the API.
type PrefixInfo struct {
IPv4 []string `json:"ipv4"`
IPv6 []string `json:"ipv6"`
}
// Global caches with 10 minute TTL
var ipCache = NewIPCache(10 * time.Minute)
var asnCache = NewASNCache(10 * time.Minute)
// cachedIPData represents a cached IP lookup result.
type cachedIPData struct {
data *DataStruct
time time.Time
}
// cachedASNData represents a cached ASN lookup result.
type cachedASNData struct {
data *ASNDataResponse
time time.Time
}
// IPCache provides thread-safe caching of IP lookup results
type IPCache struct {
cache sync.Map
ttl time.Duration
}
// NewIPCache creates a new IP cache with the specified TTL
func NewIPCache(ttl time.Duration) *IPCache {
return &IPCache{
ttl: ttl,
}
}
// Set adds a new entry to the IP cache
func (c *IPCache) Set(ipStr string, data *DataStruct) {
c.cache.Store(ipStr, cachedIPData{
data: data,
time: time.Now(),
})
}
// Get retrieves an entry from the IP cache
func (c *IPCache) Get(ipStr string) (*DataStruct, bool) {
if cachedData, ok := c.cache.Load(ipStr); ok {
cached := cachedData.(cachedIPData)
if time.Since(cached.time) < c.ttl {
return cached.data, true
}
c.cache.Delete(ipStr)
}
return nil, false
}
// ASNCache provides thread-safe caching of ASN lookup results
type ASNCache struct {
cache sync.Map
ttl time.Duration
}
// NewASNCache creates a new ASN cache with the specified TTL
func NewASNCache(ttl time.Duration) *ASNCache {
return &ASNCache{
ttl: ttl,
}
}
// Set adds a new entry to the ASN cache
func (c *ASNCache) Set(asn uint, data *ASNDataResponse) {
c.cache.Store(asn, cachedASNData{
data: data,
time: time.Now(),
})
}
// Get retrieves an entry from the ASN cache
func (c *ASNCache) Get(asn uint) (*ASNDataResponse, bool) {
if cachedData, ok := c.cache.Load(asn); ok {
cached := cachedData.(cachedASNData)
if time.Since(cached.time) < c.ttl {
return cached.data, true
}
c.cache.Delete(asn)
}
return nil, false
}
// LookupIPData looks up IP data in the databases with caching
func LookupIPData(geoIP *db.GeoIPManager, ip net.IP) *DataStruct {
if data, found := ipCache.Get(ip.String()); found {
return data
}
var cityRecord struct {
City struct {
Names map[string]string `maxminddb:"names"`
} `maxminddb:"city"`
Subdivisions []struct {
Names map[string]string `maxminddb:"names"`
} `maxminddb:"subdivisions"`
Country struct {
IsoCode string `maxminddb:"iso_code"`
Names map[string]string `maxminddb:"names"`
} `maxminddb:"country"`
Location struct {
Latitude float64 `maxminddb:"latitude"`
Longitude float64 `maxminddb:"longitude"`
Timezone string `maxminddb:"time_zone"`
} `maxminddb:"location"`
}
cityDB := geoIP.GetCityDB()
err := cityDB.Lookup(ip, &cityRecord)
if err != nil {
log.Printf("Error looking up city data: %v", err)
return nil
}
var asnRecord db.ASNRecord
asnDB := geoIP.GetASNDB()
err = asnDB.Lookup(ip, &asnRecord)
if err != nil {
log.Printf("Error looking up ASN data: %v", err)
return nil
}
hostname, err := net.LookupAddr(ip.String())
if err != nil || len(hostname) == 0 {
hostname = []string{""}
}
var sd *string
if len(cityRecord.Subdivisions) > 0 {
sd = ToPtr(cityRecord.Subdivisions[0].Names["en"])
}
data := &DataStruct{
IP: ToPtr(ip.String()),
Hostname: ToPtr(strings.TrimSuffix(hostname[0], ".")),
Org: ToPtr(fmt.Sprintf("AS%d %s", asnRecord.AutonomousSystemNumber, asnRecord.AutonomousSystemOrganization)),
City: ToPtr(cityRecord.City.Names["en"]),
Region: sd,
Country: ToPtr(cityRecord.Country.IsoCode),
Timezone: ToPtr(cityRecord.Location.Timezone),
Loc: ToPtr(fmt.Sprintf("%.4f,%.4f", cityRecord.Location.Latitude, cityRecord.Location.Longitude)),
}
ipCache.Set(ip.String(), data)
return data
}
// LookupASNData looks up ASN data in the databases with caching
func LookupASNData(geoIP *db.GeoIPManager, targetASN uint) (*ASNDataResponse, error) {
if data, found := asnCache.Get(targetASN); found {
return data, nil
}
prefixes := geoIP.GetASNPrefixes(targetASN)
if len(prefixes) == 0 {
return nil, fmt.Errorf("no prefixes found for ASN %d in the database", targetASN)
}
var orgName string
var ipv4Prefixes, ipv6Prefixes []string
var record db.ASNRecord
if err := geoIP.GetASNDB().Lookup(prefixes[0].IP, &record); err == nil {
orgName = record.AutonomousSystemOrganization
}
for _, prefix := range prefixes {
prefixStr := prefix.String()
if strings.Contains(prefixStr, ":") {
ipv6Prefixes = append(ipv6Prefixes, prefixStr)
} else {
ipv4Prefixes = append(ipv4Prefixes, prefixStr)
}
}
sort.Strings(ipv4Prefixes)
sort.Strings(ipv6Prefixes)
response := &ASNDataResponse{
Details: Details{
ASN: targetASN,
Name: orgName,
},
Prefixes: PrefixInfo{
IPv4: ipv4Prefixes,
IPv6: ipv6Prefixes,
},
}
asnCache.Set(targetASN, response)
return response, nil
}
// ToPtr converts string to pointer
func ToPtr(s string) *string {
if s == "" {
return nil
}
return &s
}
// IsBogon checks if the IP is a bogon IP
func IsBogon(ip net.IP) bool {
for _, net := range iputils.BogonNets {
if net.Contains(ip) {
return true
}
}
return false
}
// GetRealIP extracts the client's real IP address from request headers
func GetRealIP(r *http.Request) string {
for _, header := range []string{"CF-Connecting-IP", "X-Real-IP", "X-Forwarded-For"} {
if ip := r.Header.Get(header); ip != "" {
return strings.TrimSpace(strings.Split(ip, ",")[0])
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
+221
View File
@@ -0,0 +1,221 @@
package common
import (
"fmt"
"log/slog"
"net"
"sort"
"strings"
"sync"
"ipinfo/internal/db"
"github.com/likexian/whois-parser"
"golang.org/x/net/publicsuffix"
)
// LookupIPData looks up IP data in the databases with caching.
func LookupIPData(geoIP *db.GeoIPManager, ip net.IP) *DataStruct {
ipStr := ip.String()
if data, found := cache.Get(ipStr); found {
return data.(*DataStruct)
}
var cityRecord struct {
City struct {
Names map[string]string `maxminddb:"names"`
} `maxminddb:"city"`
Subdivisions []struct {
Names map[string]string `maxminddb:"names"`
} `maxminddb:"subdivisions"`
Country struct {
IsoCode string `maxminddb:"iso_code"`
} `maxminddb:"country"`
Location struct {
Latitude float64 `maxminddb:"latitude"`
Longitude float64 `maxminddb:"longitude"`
Timezone string `maxminddb:"time_zone"`
} `maxminddb:"location"`
}
if err := geoIP.GetCityDB().Lookup(ip, &cityRecord); err != nil {
slog.Error("failed to look up city data", "err", err)
return nil
}
var asnRecord db.ASNRecord
if err := geoIP.GetASNDB().Lookup(ip, &asnRecord); err != nil {
slog.Error("failed to look up asn data", "err", err)
return nil
}
hostname, _ := net.LookupAddr(ipStr)
hostnameStr := ""
if len(hostname) > 0 {
hostnameStr = strings.TrimSuffix(hostname[0], ".")
}
var region *string
if len(cityRecord.Subdivisions) > 0 {
region = ToPtr(cityRecord.Subdivisions[0].Names["en"])
}
data := &DataStruct{
IP: ToPtr(ipStr),
Hostname: ToPtr(hostnameStr),
Org: ToPtr(fmt.Sprintf("AS%d %s", asnRecord.AutonomousSystemNumber, asnRecord.AutonomousSystemOrganization)),
City: ToPtr(cityRecord.City.Names["en"]),
Region: region,
Country: ToPtr(cityRecord.Country.IsoCode),
Timezone: ToPtr(cityRecord.Location.Timezone),
Loc: ToPtr(fmt.Sprintf("%.4f,%.4f", cityRecord.Location.Latitude, cityRecord.Location.Longitude)),
}
cache.Set(ipStr, data)
return data
}
// LookupASNData looks up ASN data in the databases with caching.
func LookupASNData(geoIP *db.GeoIPManager, targetASN uint) (*ASNDataResponse, error) {
if data, found := cache.Get(targetASN); found {
return data.(*ASNDataResponse), nil
}
prefixes := geoIP.GetASNPrefixes(targetASN)
if len(prefixes) == 0 {
return nil, fmt.Errorf("no prefixes found for as%d in the database", targetASN)
}
var orgName string
var record db.ASNRecord
if err := geoIP.GetASNDB().Lookup(prefixes[0].IP, &record); err == nil {
orgName = record.AutonomousSystemOrganization
}
var ipv4Prefixes, ipv6Prefixes []string
for _, prefix := range prefixes {
prefixStr := prefix.String()
if strings.Contains(prefixStr, ":") {
ipv6Prefixes = append(ipv6Prefixes, prefixStr)
} else {
ipv4Prefixes = append(ipv4Prefixes, prefixStr)
}
}
sort.Strings(ipv4Prefixes)
sort.Strings(ipv6Prefixes)
response := &ASNDataResponse{
Details: ASNDetails{
ASN: targetASN,
Name: orgName,
},
Prefixes: ASNPrefixInfo{
IPv4: ipv4Prefixes,
IPv6: ipv6Prefixes,
},
}
cache.Set(targetASN, response)
return response, nil
}
// LookupDomainData looks up domain data with caching.
func LookupDomainData(domain string) (*DomainDataResponse, error) {
if data, found := cache.Get(domain); found {
return data.(*DomainDataResponse), nil
}
eTLD, err := publicsuffix.EffectiveTLDPlusOne(domain)
if err != nil {
return nil, fmt.Errorf("invalid domain: %w", err)
}
whoisRaw, err := performWhoisWithFallback(eTLD)
var whoisResult interface{}
if err != nil {
slog.Error("whois lookup failed after fallback", "domain", eTLD, "err", err)
whoisResult = fmt.Sprintf("whois lookup failed: %v", err)
} else {
parsed, parseErr := whoisparser.Parse(whoisRaw)
if parseErr != nil {
slog.Warn("failed to parse whois data, returning raw text", "domain", eTLD, "err", parseErr)
whoisResult = whoisRaw
} else {
whoisResult = formatWhois(parsed)
}
}
dnsData := DNSData{}
var wg sync.WaitGroup
var mu sync.Mutex
lookupTasks := []func(){
func() { // A and AAAA records
ips, err := net.LookupIP(domain)
if err == nil {
mu.Lock()
defer mu.Unlock()
for _, ip := range ips {
if ip.To4() != nil {
dnsData.A = append(dnsData.A, ip.String())
} else {
dnsData.AAAA = append(dnsData.AAAA, ip.String())
}
}
}
},
func() { // CNAME record
cname, err := net.LookupCNAME(domain)
if err == nil && cname != domain+"." && cname != "" {
mu.Lock()
defer mu.Unlock()
dnsData.CNAME = strings.TrimSuffix(cname, ".")
}
},
func() { // MX records
mxs, err := net.LookupMX(domain)
if err == nil {
mu.Lock()
defer mu.Unlock()
for _, mx := range mxs {
dnsData.MX = append(dnsData.MX, fmt.Sprintf("%d %s", mx.Pref, strings.TrimSuffix(mx.Host, ".")))
}
}
},
func() { // TXT records
txts, err := net.LookupTXT(domain)
if err == nil {
mu.Lock()
defer mu.Unlock()
dnsData.TXT = append(dnsData.TXT, txts...)
}
},
func() { // NS records
nss, err := net.LookupNS(eTLD)
if err == nil {
mu.Lock()
defer mu.Unlock()
for _, ns := range nss {
dnsData.NS = append(dnsData.NS, strings.TrimSuffix(ns.Host, "."))
}
}
},
}
wg.Add(len(lookupTasks))
for _, task := range lookupTasks {
go func(t func()) {
defer wg.Done()
t()
}(task)
}
wg.Wait()
response := &DomainDataResponse{
Whois: whoisResult,
DNS: dnsData,
}
cache.Set(domain, response)
return response, nil
}
+93
View File
@@ -0,0 +1,93 @@
package common
// DataStruct represents the structure of the IP data returned by the API.
type DataStruct struct {
IP *string `json:"ip"`
Hostname *string `json:"hostname"`
Org *string `json:"org"`
City *string `json:"city"`
Region *string `json:"region"`
Country *string `json:"country"`
Timezone *string `json:"timezone"`
Loc *string `json:"loc"`
}
// ASNDataResponse represents the structure of the ASN data returned by the API.
type ASNDataResponse struct {
Details ASNDetails `json:"details"`
Prefixes ASNPrefixInfo `json:"prefixes"`
}
// ASNDetails represents the structure of the ASN details returned by the API.
type ASNDetails struct {
ASN uint `json:"asn"`
Name string `json:"name"`
}
// ASNPrefixInfo represents the structure of the ASN prefix information returned by the API.
type ASNPrefixInfo struct {
IPv4 []string `json:"ipv4"`
IPv6 []string `json:"ipv6"`
}
// DomainDataResponse represents the structure of the domain data returned by the API.
type DomainDataResponse struct {
Whois interface{} `json:"whois"`
DNS DNSData `json:"dns"`
}
// DNSData represents the structure of the DNS records.
type DNSData struct {
A []string `json:"A,omitempty"`
AAAA []string `json:"AAAA,omitempty"`
CNAME string `json:"CNAME,omitempty"`
MX []string `json:"MX,omitempty"`
TXT []string `json:"TXT,omitempty"`
NS []string `json:"NS,omitempty"`
}
// WhoisInfo is a sanitized version of the parsed whois data for the API response.
type WhoisInfo struct {
Domain *WhoisDomain `json:"domain,omitempty"`
Registrar *WhoisRegistrar `json:"registrar,omitempty"`
Registrant *WhoisContact `json:"registrant,omitempty"`
Admin *WhoisContact `json:"admin,omitempty"`
Tech *WhoisContact `json:"tech,omitempty"`
}
// WhoisDomain omits unnecessary fields from the original parsed domain struct.
type WhoisDomain struct {
ID string `json:"id,omitempty"`
Domain string `json:"domain,omitempty"`
WhoisServer string `json:"whois_server,omitempty"`
Status []string `json:"status,omitempty"`
NameServers []string `json:"name_servers,omitempty"`
DNSSEC bool `json:"dnssec"`
CreatedDate string `json:"created_date,omitempty"`
UpdatedDate string `json:"updated_date,omitempty"`
ExpirationDate string `json:"expiration_date,omitempty"`
}
// WhoisRegistrar contains registrar information.
type WhoisRegistrar struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Email string `json:"email,omitempty"`
Phone string `json:"phone,omitempty"`
ReferralURL string `json:"referral_url,omitempty"`
}
// WhoisContact contains contact information for registrant, admin, or tech.
type WhoisContact struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Organization string `json:"organization,omitempty"`
Street string `json:"street,omitempty"`
City string `json:"city,omitempty"`
Province string `json:"province,omitempty"`
PostalCode string `json:"postal_code,omitempty"`
Country string `json:"country,omitempty"`
Phone string `json:"phone,omitempty"`
Fax string `json:"fax,omitempty"`
Email string `json:"email,omitempty"`
}
+25
View File
@@ -0,0 +1,25 @@
package common
import (
"net"
"ipinfo/utils"
)
// ToPtr converts a string to a pointer, returning nil for empty strings.
func ToPtr(s string) *string {
if s == "" {
return nil
}
return &s
}
// IsBogon checks if the IP is a bogon IP.
func IsBogon(ip net.IP) bool {
for _, network := range utils.BogonNets {
if network.Contains(ip) {
return true
}
}
return false
}
+187
View File
@@ -0,0 +1,187 @@
package common
import (
"bufio"
"fmt"
"io"
"log/slog"
"net"
"strings"
"time"
"github.com/likexian/whois"
"github.com/likexian/whois-parser"
)
// performWhoisWithFallback attempts a WHOIS query and falls back to IPv4 if it suspects an IPv6 issue.
func performWhoisWithFallback(domain string) (string, error) {
result, err := whois.Whois(domain)
if err == nil {
return result, nil
}
if strings.Contains(err.Error(), "dial tcp [") && strings.Contains(err.Error(), "]:43") {
slog.Warn("whois failed with potential ipv6 issue, falling back to ipv4", "domain", domain, "err", err)
serverHost, serverErr := getWhoisServerForDomain(domain)
if serverErr != nil {
slog.Error("could not find whois server during fallback", "domain", domain, "err", serverErr)
return "", err
}
ips, resolveErr := net.LookupIP(serverHost)
if resolveErr != nil {
slog.Error("could not resolve whois server hostname during fallback", "server", serverHost, "err", resolveErr)
return "", err
}
for _, ip := range ips {
if ip.To4() != nil {
ipv4Server := ip.String()
slog.Info("retrying whois query with explicit ipv4 address", "domain", domain, "server", ipv4Server)
return queryWhoisServer(domain, ipv4Server)
}
}
slog.Warn("no ipv4 address found for whois server during fallback", "server", serverHost)
}
return "", err
}
// getWhoisServerForDomain finds the authoritative WHOIS server for a domain by querying IANA.
func getWhoisServerForDomain(domain string) (string, error) {
parts := strings.Split(domain, ".")
if len(parts) < 2 {
return "", fmt.Errorf("invalid domain: %s", domain)
}
tld := parts[len(parts)-1]
conn, err := net.Dial("tcp", "whois.iana.org:43")
if err != nil {
return "", fmt.Errorf("could not connect to iana whois server: %w", err)
}
defer func() {
if err := conn.Close(); err != nil {
slog.Warn("error closing connection to iana whois server", "err", err)
}
}()
_, err = conn.Write([]byte(tld + "\r\n"))
if err != nil {
return "", fmt.Errorf("could not send query to iana whois server: %w", err)
}
scanner := bufio.NewScanner(conn)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(strings.ToLower(line), "whois:") {
serverParts := strings.Fields(line)
if len(serverParts) > 1 {
return serverParts[1], nil
}
}
}
if err := scanner.Err(); err != nil {
return "", fmt.Errorf("error reading from iana whois server: %w", err)
}
return "", fmt.Errorf("could not find whois server for TLD: %s", tld)
}
// queryWhoisServer manually performs a WHOIS query to a specific server IP.
func queryWhoisServer(domain, serverIP string) (string, error) {
conn, err := net.DialTimeout("tcp", net.JoinHostPort(serverIP, "43"), 10*time.Second)
if err != nil {
return "", fmt.Errorf("could not connect to %s: %w", serverIP, err)
}
defer func() {
if err := conn.Close(); err != nil {
slog.Warn("error closing connection to whois server", "server", serverIP, "err", err)
}
}()
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
_, err = conn.Write([]byte(domain + "\r\n"))
if err != nil {
return "", fmt.Errorf("could not send query to %s: %w", serverIP, err)
}
body, err := io.ReadAll(conn)
if err != nil {
return "", fmt.Errorf("could not read response from %s: %w", serverIP, err)
}
return string(body), nil
}
// formatWhois converts a parsed whois object to the simplified WhoisInfo struct.
func formatWhois(parsed whoisparser.WhoisInfo) WhoisInfo {
info := WhoisInfo{}
if parsed.Domain != nil {
info.Domain = &WhoisDomain{
ID: parsed.Domain.ID,
Domain: parsed.Domain.Domain,
WhoisServer: parsed.Domain.WhoisServer,
Status: parsed.Domain.Status,
NameServers: parsed.Domain.NameServers,
DNSSEC: parsed.Domain.DNSSec,
CreatedDate: parsed.Domain.CreatedDate,
UpdatedDate: parsed.Domain.UpdatedDate,
ExpirationDate: parsed.Domain.ExpirationDate,
}
}
if parsed.Registrar != nil {
info.Registrar = &WhoisRegistrar{
ID: parsed.Registrar.ID,
Name: parsed.Registrar.Name,
Email: parsed.Registrar.Email,
Phone: parsed.Registrar.Phone,
ReferralURL: parsed.Registrar.ReferralURL,
}
}
if parsed.Registrant != nil {
info.Registrant = &WhoisContact{
ID: parsed.Registrant.ID,
Name: parsed.Registrant.Name,
Organization: parsed.Registrant.Organization,
Street: parsed.Registrant.Street,
City: parsed.Registrant.City,
Province: parsed.Registrant.Province,
PostalCode: parsed.Registrant.PostalCode,
Country: parsed.Registrant.Country,
Phone: parsed.Registrant.Phone,
Fax: parsed.Registrant.Fax,
Email: parsed.Registrant.Email,
}
}
if parsed.Administrative != nil {
info.Admin = &WhoisContact{
ID: parsed.Administrative.ID,
Name: parsed.Administrative.Name,
Organization: parsed.Administrative.Organization,
Street: parsed.Administrative.Street,
City: parsed.Administrative.City,
Province: parsed.Administrative.Province,
PostalCode: parsed.Administrative.PostalCode,
Country: parsed.Administrative.Country,
Phone: parsed.Administrative.Phone,
Fax: parsed.Administrative.Fax,
Email: parsed.Administrative.Email,
}
}
if parsed.Technical != nil {
info.Tech = &WhoisContact{
ID: parsed.Technical.ID,
Name: parsed.Technical.Name,
Organization: parsed.Technical.Organization,
Street: parsed.Technical.Street,
City: parsed.Technical.City,
Province: parsed.Technical.Province,
PostalCode: parsed.Technical.PostalCode,
Country: parsed.Technical.Country,
Phone: parsed.Technical.Phone,
Fax: parsed.Technical.Fax,
Email: parsed.Technical.Email,
}
}
return info
}