NEW DNS PROVIDER: Realtime Register (REALTIMEREGISTER) (#2741)

Co-authored-by: pieterjan.eilers <pieterjan.eilers@realtimeregister.com>
Co-authored-by: Tom Limoncelli <tlimoncelli@stackoverflow.com>
This commit is contained in:
PJEilers 2024-01-09 16:45:59 +01:00 committed by GitHub
parent f46004eff9
commit 3d570ead31
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 652 additions and 1 deletions

View file

@ -36,7 +36,7 @@ changelog:
regexp: "(?i)^.*(major|new provider|feature)[(\\w)]*:+.*$"
order: 1
- title: 'Provider-specific changes:'
regexp: "(?i)((akamaiedge|autodns|axfrd|azure|azure_private_dns|bind|bunnydns|cloudflare|cloudflareapi_old|cloudns|cscglobal|desec|digitalocean|dnsimple|dnsmadeeasy|doh|domainnameshop|dynadot|easyname|exoscale|gandi|gcloud|gcore|hedns|hetzner|hexonet|hostingde|inwx|linode|loopia|luadns|msdns|mythicbeasts|namecheap|namedotcom|netcup|netlify|ns1|opensrs|oracle|ovh|packetframe|porkbun|powerdns|route53|rwth|softlayer|transip|vultr).*:)+.*"
regexp: "(?i)((akamaiedge|autodns|axfrd|azure|azure_private_dns|bind|bunnydns|cloudflare|cloudflareapi_old|cloudns|cscglobal|desec|digitalocean|dnsimple|dnsmadeeasy|doh|domainnameshop|dynadot|easyname|exoscale|gandi|gcloud|gcore|hedns|hetzner|hexonet|hostingde|inwx|linode|loopia|luadns|msdns|mythicbeasts|namecheap|namedotcom|netcup|netlify|ns1|opensrs|oracle|ovh|packetframe|porkbun|powerdns|realtimeregister|route53|rwth|softlayer|transip|vultr).*:)+.*"
order: 2
- title: 'Documentation:'
regexp: "(?i)^.*(docs)[(\\w)]*:+.*$"

1
OWNERS
View file

@ -42,6 +42,7 @@ providers/ovh @masterzen
providers/packetframe @hamptonmoore
providers/porkbun @imlonghao
providers/powerdns @jpbede
providers/realtimeregister @PJEilers
providers/route53 @tresni
providers/rwth @mistererwin
# providers/softlayer NEEDS VOLUNTEER

View file

@ -55,6 +55,7 @@ Currently supported DNS providers:
- Packetframe
- Porkbun
- PowerDNS
- Realtime Register
- RWTH DNS-Admin
- SoftLayer
- TransIP
@ -76,6 +77,7 @@ Currently supported Domain Registrars:
- Name.com
- OpenSRS
- OVH
- Realtime Register
At Stack Overflow, we use this system to manage hundreds of domains
and subdomains across multiple registrars and DNS providers.

View file

@ -142,6 +142,7 @@
* [Packetframe](providers/packetframe.md)
* [Porkbun](providers/porkbun.md)
* [PowerDNS](providers/powerdns.md)
* [Realtime Register](providers/realtimeregister.md)
* [RWTH DNS-Admin](providers/rwth.md)
* [SoftLayer DNS](providers/softlayer.md)
* [TransIP](providers/transip.md)

View file

@ -58,6 +58,7 @@ If a feature is definitively not supported for whatever reason, we would also li
| [`PACKETFRAME`](providers/packetframe.md) | ❌ | ✅ | ❌ | ❔ | ❔ | ❔ | ❔ | ❔ | ✅ | ❔ | ✅ | ❔ | ❔ | ❔ | ❔ | ❌ | ❌ | ✅ | ❔ |
| [`PORKBUN`](providers/porkbun.md) | ❌ | ✅ | ✅ | ✅ | ❔ | ❌ | ❌ | ❌ | ❌ | ❌ | ✅ | ❌ | ✅ | ❌ | ❔ | ❌ | ❌ | ✅ | ✅ |
| [`POWERDNS`](providers/powerdns.md) | ❌ | ✅ | ❌ | ✅ | ✅ | ✅ | ❔ | ✅ | ✅ | ❔ | ✅ | ✅ | ✅ | ✅ | ❔ | ✅ | ✅ | ✅ | ✅ |
| [`REALTIMEREGISTER`](providers/realtimeregister.md) | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ |
| [`ROUTE53`](providers/route53.md) | ✅ | ✅ | ✅ | ❌ | ✅ | ❔ | ❌ | ❔ | ✅ | ❔ | ✅ | ❔ | ❔ | ❔ | ❔ | ✅ | ✅ | ✅ | ✅ |
| [`RWTH`](providers/rwth.md) | ❌ | ✅ | ❌ | ❌ | ✅ | ❔ | ❌ | ❌ | ✅ | ❔ | ✅ | ✅ | ❌ | ❔ | ❔ | ❌ | ❌ | ✅ | ✅ |
| [`SOFTLAYER`](providers/softlayer.md) | ❌ | ✅ | ❌ | ❔ | ❔ | ❔ | ❌ | ❔ | ❔ | ❔ | ✅ | ❔ | ❔ | ❔ | ❔ | ❔ | ❌ | ✅ | ❔ |
@ -143,6 +144,7 @@ Providers in this category and their maintainers are:
|[`OVH`](providers/ovh.md)|@masterzen|
|[`PACKETFRAME`](providers/packetframe.md)|@hamptonmoore|
|[`POWERDNS`](providers/powerdns.md)|@jpbede|
|[`REALTIMEREGISTER`](providers/realtimeregister.md)|@PJEilers|
|[`ROUTE53`](providers/route53.md)|@tresni|
|[`RWTH`](providers/rwth.md)|@MisterErwin|
|[`SOFTLAYER`](providers/softlayer.md)|@jamielennox|

View file

@ -0,0 +1,46 @@
[realtimeregister.com](https://realtimeregister.com) is a domain registrar based in the Netherlands.
## Configuration
To use this provider, add an entry to `creds.json` with `TYPE` set to `REALTIMEREGISTER`
along with your API-key. Further configuration includes a flag indicating BASIC or PREMIUM DNS-service and a flag
indicating the use of the sandbox environment
**Example:**
{% code title="creds.json" %}
```json
{
"realtimeregister": {
"TYPE": "REALTIMEREGISTER",
"apikey": "abcdefghijklmnopqrstuvwxyz1234567890",
"sandbox" : "0",
"premium" : "0"
}
}
```
{% endcode %}
If sandbox is omitted or set to any other value than "1" the production API will be used.
If premium is set to "1", you will only be able to update zones using Premium DNS. If it is omitted or set to any other value, you
will only be able to update zones using Basic DNS.
**Important Notes**:
* Anyone with access to this `creds.json` file will have *full* access to your RTR account and will be able to transfer or delete your domains
## Metadata
This provider does not recognize any special metadata fields unique to Realtime Register.
## Usage
An example `dnsconfig.js` configuration file
{% code title="dnsconfig.js" %}
```javascript
var REG_RTR = NewRegistrar("realtimeregister");
var DSP_RTR = NewDnsProvider("realtimeregister");
D("example.com", REG_RTR, DnsProvider(DSP_RTR),
A("test", "1.2.3.4")
);
```
{% endcode %}

View file

@ -258,6 +258,13 @@
"domain": "$POWERDNS_DOMAIN",
"serverName": "$POWERDNS_SERVERNAME"
},
"REALTIMEREGISTER": {
"TYPE": "REALTIMEREGISTER",
"apikey": "$REALTIMEREGISTER_APIKEY",
"sandbox" : "$REALTIMEREGISTER_SANDBOX",
"domain": "$REALTIMEREGISTER_DOMAIN",
"premium": "$REALTIMEREGISTER_PREMIUM"
},
"ROUTE53": {
"KeyId": "$ROUTE53_KEY_ID",
"SecretKey": "$ROUTE53_KEY",

View file

@ -47,6 +47,7 @@ import (
_ "github.com/StackExchange/dnscontrol/v4/providers/packetframe"
_ "github.com/StackExchange/dnscontrol/v4/providers/porkbun"
_ "github.com/StackExchange/dnscontrol/v4/providers/powerdns"
_ "github.com/StackExchange/dnscontrol/v4/providers/realtimeregister"
_ "github.com/StackExchange/dnscontrol/v4/providers/route53"
_ "github.com/StackExchange/dnscontrol/v4/providers/rwth"
_ "github.com/StackExchange/dnscontrol/v4/providers/softlayer"

View file

@ -0,0 +1,221 @@
package realtimeregister
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
type realtimeregisterAPI struct {
apikey string
endpoint string
Zones map[string]*Zone //cache
ServiceType string
}
type Zones struct {
Entities []Zone `json:"entities"`
}
type Domain struct {
Nameservers []string `json:"ns"`
}
type Zone struct {
Name string `json:"name,omitempty"`
Service string `json:"service,omitempty"`
ID int `json:"id,omitempty"`
Records []Record `json:"records"`
Dnssec bool `json:"dnssec"`
}
type Record struct {
Name string `json:"name"`
Type string `json:"type"`
Content string `json:"content"`
Priority int `json:"prio,omitempty"`
TTL int `json:"ttl"`
}
const (
endpoint = "https://api.yoursrs.com/v2"
endpointSandbox = "https://api.yoursrs-ote.com/v2"
)
func (api *realtimeregisterAPI) request(method string, url string, body io.Reader) ([]byte, error) {
client := &http.Client{}
req, _ := http.NewRequest(
method,
url,
body,
)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "ApiKey "+api.apikey)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
bodyString, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("realtime Register API error on request to %s: %d, %s", url, resp.StatusCode,
string(bodyString))
}
return bodyString, nil
}
func (api *realtimeregisterAPI) getZone(domain string) (*Zone, error) {
zones, err := api.getDomainZones(domain)
if err != nil {
return nil, err
}
if len(zones.Entities) == 0 {
return nil, fmt.Errorf("zone %s does not exist", domain)
}
api.Zones[domain] = &zones.Entities[0]
return &zones.Entities[0], nil
}
func (api *realtimeregisterAPI) getDomainZones(domain string) (*Zones, error) {
url := fmt.Sprintf(api.endpoint+"/dns/zones?name=%s&service=%s", domain, api.ServiceType)
return api.getZones(url)
}
func (api *realtimeregisterAPI) getAllZones() ([]string, error) {
url := fmt.Sprintf(api.endpoint+"/dns/zones?service=%s&export=true&fields=id,name", api.ServiceType)
zones, err := api.getZones(url)
if err != nil {
return nil, err
}
zoneNames := make([]string, len(zones.Entities))
for i, zone := range zones.Entities {
zoneNames[i] = zone.Name
}
return zoneNames, nil
}
func (api *realtimeregisterAPI) getZones(url string) (*Zones, error) {
bodyBytes, err := api.request(
"GET",
url,
nil,
)
if err != nil {
return nil, err
}
respData := &Zones{}
err = json.Unmarshal(bodyBytes, &respData)
if err != nil {
return nil, err
}
return respData, nil
}
func (api *realtimeregisterAPI) createZone(domain string) error {
zone := &Zone{
Records: []Record{},
Name: domain,
Service: api.ServiceType,
}
err := api.createOrUpdateZone(zone, api.endpoint+"/dns/zones")
if err != nil {
return err
}
return nil
}
func (api *realtimeregisterAPI) zoneExists(domain string) (bool, error) {
if api.Zones[domain] != nil {
return true, nil
}
zones, err := api.getDomainZones(domain)
if err != nil {
return false, err
}
return len(zones.Entities) > 0, nil
}
func (api *realtimeregisterAPI) getDomainNameservers(domainName string) ([]string, error) {
respData, err := api.request(
"GET",
fmt.Sprintf(api.endpoint+"/domains/%s", domainName),
nil,
)
if err != nil {
return nil, err
}
domain := &Domain{}
err = json.Unmarshal(respData, &domain)
if err != nil {
return nil, err
}
return domain.Nameservers, nil
}
func (api *realtimeregisterAPI) updateZone(domain string, body *Zone) error {
return api.createOrUpdateZone(
body,
fmt.Sprintf(api.endpoint+"/dns/zones/%d/update", api.Zones[domain].ID),
)
}
func (api *realtimeregisterAPI) updateNameservers(domainName string, nameservers []string) error {
domain := &Domain{
Nameservers: nameservers,
}
bodyBytes, err := json.Marshal(domain)
if err != nil {
return err
}
_, err = api.request(
"POST",
fmt.Sprintf(api.endpoint+"/domains/%s/update", domainName),
bytes.NewReader(bodyBytes),
)
if err != nil {
return err
}
return nil
}
func (api *realtimeregisterAPI) createOrUpdateZone(body *Zone, url string) error {
bodyBytes, err := json.Marshal(body)
if err != nil {
return err
}
//Ugly hack for MX records with null target
requestBody := strings.Replace(string(bodyBytes), "\"prio\":-1", "\"prio\":0", -1)
_, err = api.request("POST", url, strings.NewReader(requestBody))
if err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,19 @@
package realtimeregister
import (
"github.com/StackExchange/dnscontrol/v4/models"
"github.com/StackExchange/dnscontrol/v4/pkg/rejectif"
)
// AuditRecords returns a list of errors corresponding to the records
// that aren't supported by this provider. If all records are
// supported, an empty list is returned.
func AuditRecords(records []*models.RecordConfig) []error {
auditor := rejectif.Auditor{}
auditor.Add("TXT", rejectif.TxtHasTrailingSpace) // Last verified 2024-01-03
auditor.Add("TXT", rejectif.TxtIsEmpty) // Last verified 2024-01-03
return auditor.Audit(records)
}

View file

@ -0,0 +1,335 @@
package realtimeregister
import (
"encoding/json"
"fmt"
"github.com/StackExchange/dnscontrol/v4/models"
"github.com/StackExchange/dnscontrol/v4/pkg/diff2"
"github.com/StackExchange/dnscontrol/v4/providers"
"github.com/miekg/dns/dnsutil"
"golang.org/x/exp/slices"
"sort"
"strconv"
"strings"
)
/*
Realtime Register DNS provider
Info required in `creds.json`:
- apikey
- premium: (0 for BASIC or 1 for PREMIUM)
Additional settings available in `creds.json`:
- sandbox (set to 1 to use the sandbox API from realtime register)
*/
var features = providers.DocumentationNotes{
providers.CanAutoDNSSEC: providers.Can(),
providers.CanGetZones: providers.Can(),
providers.CanUseAlias: providers.Can(),
providers.CanUseCAA: providers.Can(),
providers.CanUseDHCID: providers.Cannot(),
providers.CanUseDS: providers.Cannot("Only for subdomains"),
providers.CanUseDSForChildren: providers.Can(),
providers.CanUseLOC: providers.Can(),
providers.CanUseNAPTR: providers.Can(),
providers.CanUsePTR: providers.Cannot(),
providers.CanUseSRV: providers.Can(),
providers.CanUseSSHFP: providers.Can(),
providers.CanUseSOA: providers.Cannot(),
providers.CanUseTLSA: providers.Can(),
providers.DocCreateDomains: providers.Can(),
providers.DocDualHost: providers.Cannot(),
providers.DocOfficiallySupported: providers.Cannot(),
}
// init registers the domain service provider with dnscontrol.
func init() {
fns := providers.DspFuncs{
Initializer: newRtrDsp,
RecordAuditor: AuditRecords,
}
providers.RegisterDomainServiceProviderType("REALTIMEREGISTER", fns, features)
providers.RegisterRegistrarType("REALTIMEREGISTER", newRtrReg)
}
func newRtr(config map[string]string, metadata json.RawMessage) (*realtimeregisterAPI, error) {
apikey := config["apikey"]
sandbox := config["sandbox"] == "1"
if apikey == "" {
return nil, fmt.Errorf("realtime register: apikey must be provided")
}
api := &realtimeregisterAPI{
apikey: apikey,
endpoint: getEndpoint(sandbox),
Zones: make(map[string]*Zone),
ServiceType: getServiceType(config["premium"] == "1"),
}
return api, nil
}
func newRtrDsp(config map[string]string, metadata json.RawMessage) (providers.DNSServiceProvider, error) {
return newRtr(config, metadata)
}
func newRtrReg(config map[string]string) (providers.Registrar, error) {
return newRtr(config, nil)
}
// GetNameservers Default name servers should not be included in the update
func (api *realtimeregisterAPI) GetNameservers(domain string) ([]*models.Nameserver, error) {
return []*models.Nameserver{}, nil
}
func (api *realtimeregisterAPI) GetZoneRecords(domain string, meta map[string]string) (models.Records, error) {
response, err := api.getZone(domain)
if err != nil {
return nil, err
}
records := response.Records
recordConfigs := make([]*models.RecordConfig, len(records))
for i := range records {
recordConfigs[i] = toRecordConfig(domain, &records[i])
}
return recordConfigs, nil
}
func (api *realtimeregisterAPI) GetZoneRecordsCorrections(dc *models.DomainConfig, existing models.Records) ([]*models.Correction, error) {
msgs, changes, err := diff2.ByZone(existing, dc, nil)
if err != nil {
return nil, err
}
var corrections []*models.Correction
if !changes {
return corrections, nil
}
dnssec := api.Zones[dc.Name].Dnssec
if api.Zones[dc.Name].Dnssec && dc.AutoDNSSEC == "off" {
dnssec = false
corrections = append(corrections,
&models.Correction{
Msg: "Update DNSSEC on -> off",
F: func() error {
return nil
},
})
}
if !api.Zones[dc.Name].Dnssec && dc.AutoDNSSEC == "on" {
dnssec = true
corrections = append(corrections,
&models.Correction{
Msg: "Update DNSSEC off -> on",
F: func() error {
return nil
},
})
}
if changes {
corrections = append(corrections,
&models.Correction{
Msg: strings.Join(msgs, "\n"),
F: func() error {
records := make([]Record, len(dc.Records))
for i, r := range dc.Records {
records[i] = toRecord(r)
}
zone := &Zone{Records: records, Dnssec: dnssec}
err := api.updateZone(dc.Name, zone)
if err != nil {
return err
}
return nil
},
})
}
return corrections, nil
}
func (api *realtimeregisterAPI) ListZones() ([]string, error) {
zones, err := api.getAllZones()
if err != nil {
return nil, err
}
return zones, nil
}
func (api *realtimeregisterAPI) GetRegistrarCorrections(dc *models.DomainConfig) ([]*models.Correction, error) {
nameservers, err := api.getDomainNameservers(dc.Name)
if err != nil {
return nil, err
}
expected := make([]string, len(dc.Nameservers))
for i, ns := range dc.Nameservers {
expected[i] = removeTrailingDot(ns.Name)
}
sort.Strings(nameservers)
sort.Strings(expected)
if !slices.Equal(nameservers, expected) {
return []*models.Correction{
{
Msg: fmt.Sprintf("Update nameservers %s -> %s",
strings.Join(nameservers, ","), strings.Join(expected, ",")),
F: func() error { return api.updateNameservers(dc.Name, expected) },
},
}, nil
}
return nil, nil
}
func toRecordConfig(domain string, record *Record) *models.RecordConfig {
recordConfig := &models.RecordConfig{
Type: record.Type,
TTL: uint32(record.TTL),
MxPreference: uint16(record.Priority),
SrvWeight: uint16(0),
SrvPort: uint16(0),
Original: record,
}
recordConfig.SetLabelFromFQDN(record.Name, domain)
switch rtype := record.Type; rtype { // #rtype_variations
case "TXT":
_ = recordConfig.SetTargetTXT(removeEscapeChars(record.Content))
case "NS", "ALIAS", "CNAME":
_ = recordConfig.SetTarget(dnsutil.AddOrigin(addTrailingDot(record.Content), domain))
case "MX":
content := record.Content
if content != "." {
content = addTrailingDot(content)
}
_ = recordConfig.SetTarget(dnsutil.AddOrigin(content, domain))
case "NAPTR":
_ = recordConfig.SetTargetNAPTRString(record.Content)
case "SRV":
parts := strings.Fields(record.Content)
weight, _ := strconv.ParseUint(parts[0], 10, 16)
port, _ := strconv.ParseUint(parts[1], 10, 16)
content := parts[2]
if content != "." {
content = addTrailingDot(content)
}
_ = recordConfig.SetTargetSRV(uint16(record.Priority), uint16(weight), uint16(port), content)
case "CAA":
_ = recordConfig.SetTargetCAAString(record.Content)
case "SSHFP":
_ = recordConfig.SetTargetSSHFPString(record.Content)
case "TLSA":
_ = recordConfig.SetTargetTLSAString(record.Content)
case "DS":
_ = recordConfig.SetTargetDSString(record.Content)
case "LOC":
_ = recordConfig.SetTargetLOCString(domain, record.Content)
default:
_ = recordConfig.SetTarget(record.Content)
}
return recordConfig
}
func toRecord(recordConfig *models.RecordConfig) Record {
record := &Record{
Type: recordConfig.Type,
Name: recordConfig.NameFQDN,
Content: removeTrailingDot(recordConfig.GetTargetField()),
TTL: int(recordConfig.TTL),
}
switch rtype := recordConfig.Type; rtype {
case "SRV":
if record.Content == "" {
record.Content = "."
}
record.Priority = int(recordConfig.SrvPriority)
record.Content = fmt.Sprintf("%d %d %s", recordConfig.SrvWeight, recordConfig.SrvPort, record.Content)
case "NAPTR", "SSHFP", "TLSA", "CAA":
record.Content = recordConfig.GetTargetCombined()
case "TXT":
record.Content = addEscapeChars(record.Content)
case "DS":
record.Content = fmt.Sprintf("%d %d %d %s", recordConfig.DsKeyTag, recordConfig.DsAlgorithm,
recordConfig.DsDigestType, strings.ToUpper(recordConfig.DsDigest))
case "MX":
if record.Content == "" {
record.Content = "."
record.Priority = -1
} else {
record.Priority = int(recordConfig.MxPreference)
}
case "LOC":
parts := strings.Fields(recordConfig.GetTargetCombined())
degrees1, _ := strconv.ParseUint(parts[0], 10, 32)
minutes1, _ := strconv.ParseUint(parts[1], 10, 32)
degrees2, _ := strconv.ParseUint(parts[4], 10, 32)
minutes2, _ := strconv.ParseUint(parts[5], 10, 32)
altitude, _ := strconv.ParseFloat(strings.Split(parts[8], "m")[0], 64)
size, _ := strconv.ParseFloat(strings.Split(parts[9], "m")[0], 64)
hp, _ := strconv.ParseFloat(strings.Split(parts[10], "m")[0], 64)
vp, _ := strconv.ParseFloat(strings.Split(parts[11], "m")[0], 64)
record.Content = fmt.Sprintf("%d %d %s %s %d %d %s %s %.2fm %.2fm %.2fm %.2fm",
degrees1, minutes1, parts[2], parts[3], degrees2, minutes2,
parts[6], parts[7], altitude, size, hp, vp,
)
}
return *record
}
func (api *realtimeregisterAPI) EnsureZoneExists(domain string) error {
exists, err := api.zoneExists(domain)
if err != nil {
return err
}
if exists {
return nil
}
return api.createZone(domain)
}
func removeTrailingDot(record string) string {
return strings.TrimSuffix(record, ".")
}
func addTrailingDot(record string) string {
return record + "."
}
func removeEscapeChars(name string) string {
return strings.Replace(strings.Replace(name, "\\\"", "\"", -1), "\\\\", "\\", -1)
}
func addEscapeChars(name string) string {
return strings.Replace(strings.Replace(name, "\\", "\\\\", -1), "\"", "\\\"", -1)
}
func getEndpoint(sandbox bool) string {
if sandbox {
return endpointSandbox
}
return endpoint
}
func getServiceType(premium bool) string {
if premium {
return "PREMIUM"
}
return "BASIC"
}

View file

@ -0,0 +1,16 @@
package realtimeregister
import (
"github.com/stretchr/testify/assert"
"testing"
)
func TestRemoveEscapeChars(t *testing.T) {
cleanedString := removeEscapeChars("\\\\\\\"")
assert.Equal(t, "\\\"", cleanedString)
}
func TestAddEscapeChars(t *testing.T) {
addedString := addEscapeChars("\\\"")
assert.Equal(t, "\\\\\\\"", addedString)
}