diff --git a/README.md b/README.md index 47f458a..ed8207e 100644 --- a/README.md +++ b/README.md @@ -15,35 +15,9 @@ EPP ([Extensible Provisioning Protocol](https://tools.ietf.org/html/rfc5730)) cl ## Usage ```go -tconn, err := tls.Dial("tcp", "epp.example.com:700", nil) -if err != nil { - return err -} - -conn, err := epp.NewConn(tconn) -if err != nil { - return err -} - -err = conn.Login(user, password, "") -if err != nil { - return err -} - -dcr, err := conn.CheckDomain("google.com") -if err != nil { - return err -} -for _, r := range dcr.Checks { - // ... -} +// TODO: document new API ``` -## Todo - -- [X] Tests -- [ ] Commands other than `Check` - ## Author © 2021 nb.io LLC diff --git a/bool_test.go b/bool_test.go deleted file mode 100644 index 25b06bd..0000000 --- a/bool_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package epp - -import ( - "encoding/xml" - "testing" - - "github.com/nbio/st" -) - -func TestBool(t *testing.T) { - x := []byte(``) - var y struct { - XMLName struct{} `xml:"example"` - Fred Bool `xml:"fred"` - Jane Bool `xml:"jane"` - Susan Bool `xml:"susan"` - } - - err := xml.Unmarshal(x, &y) - st.Expect(t, err, nil) - st.Expect(t, y.Fred, True) - st.Expect(t, y.Jane, False) - st.Expect(t, y.Susan, True) - z, err := xml.Marshal(&y) - st.Expect(t, err, nil) - st.Expect(t, string(z), ``) -} - -func TestBoolAttr(t *testing.T) { - x := []byte(``) - var y struct { - XMLName struct{} `xml:"example"` - Fred Bool `xml:"fred,attr"` - Jane Bool `xml:"jane,attr"` - Susan Bool `xml:"susan,attr"` - } - - err := xml.Unmarshal(x, &y) - st.Expect(t, err, nil) - st.Expect(t, y.Fred, True) - st.Expect(t, y.Jane, False) - st.Expect(t, y.Susan, False) - z, err := xml.Marshal(&y) - st.Expect(t, err, nil) - st.Expect(t, string(z), ``) -} diff --git a/check.go b/check.go deleted file mode 100644 index 26c9bee..0000000 --- a/check.go +++ /dev/null @@ -1,457 +0,0 @@ -package epp - -import ( - "bytes" - "encoding/xml" - "fmt" - "strings" - - "github.com/nbio/xx" -) - -// CheckDomain queries the EPP server for the availability status of one or more domains. -func (c *Conn) CheckDomain(domains ...string) (*DomainCheckResponse, error) { - return c.CheckDomainExtensions(domains, nil) -} - -// CheckDomainExtensions allows specifying extension data for the following: -// - "neulevel:unspec": a string of the Key=Value data for the unspec tag -// - "launch:phase": a string of the launch phase -func (c *Conn) CheckDomainExtensions(domains []string, extData map[string]string) (*DomainCheckResponse, error) { - x, err := encodeDomainCheck(&c.Greeting, domains, extData) - if err != nil { - return nil, err - } - - err = c.writeRequest(x) - if err != nil { - return nil, err - } - - res, err := c.readResponse() - if err != nil { - return nil, err - } - - // The ARI price extension won't return both availability and price data - // in the same response, so we have to make a separate request for price - if c.Greeting.SupportsExtension(ExtPrice) { - x, err = encodePriceCheck(domains) - if err != nil { - return nil, err - } - err = c.writeRequest(x) - if err != nil { - return nil, err - } - res2, err := c.readResponse() - if err != nil { - return nil, err - } - res.DomainCheckResponse.Charges = res2.DomainCheckResponse.Charges - } - - return &res.DomainCheckResponse, nil - -} - -func encodeDomainCheck(greeting *Greeting, domains []string, extData map[string]string) ([]byte, error) { - buf := bytes.NewBufferString(xmlCommandPrefix) - buf.WriteString(``) - buf.WriteString(``) - for _, domain := range domains { - buf.WriteString(``) - xml.EscapeText(buf, []byte(domain)) - buf.WriteString(``) - } - buf.WriteString(``) - buf.WriteString(``) - - var feeURN string - switch { - case greeting.SupportsExtension(ExtFee10): - feeURN = ExtFee10 - case greeting.SupportsExtension(ExtFee21): - feeURN = ExtFee21 - case greeting.SupportsExtension(ExtFee11): - feeURN = ExtFee11 - // Versions 0.8-0.9 require the returned class to be "standard" for - // non-premium domains - case greeting.SupportsExtension(ExtFee08): - feeURN = ExtFee08 - case greeting.SupportsExtension(ExtFee09): - feeURN = ExtFee09 - // Version 0.5 has an attribute premium="1" for premium domains - case greeting.SupportsExtension(ExtFee05): - feeURN = ExtFee05 - // Version 0.6 and 0.7 don't have a standard way of detecting premiums, - // so instead there must be matching done on class names - case greeting.SupportsExtension(ExtFee06): - feeURN = ExtFee06 - case greeting.SupportsExtension(ExtFee07): - feeURN = ExtFee07 - } - - supportsLaunch := extData["launch:phase"] != "" && greeting.SupportsExtension(ExtLaunch) - supportsFeePhase := extData["fee:phase"] != "" - supportsNeulevel := extData["neulevel:unspec"] != "" && (greeting.SupportsExtension(ExtNeulevel) || greeting.SupportsExtension(ExtNeulevel10)) - supportsNamestore := extData["namestoreExt:subProduct"] != "" && greeting.SupportsExtension(ExtNamestore) - - hasExtension := feeURN != "" || supportsLaunch || supportsNeulevel || supportsNamestore - - if hasExtension { - buf.WriteString(``) - } - - // https://www.verisign.com/assets/epp-sdk/verisign_epp-extension_namestoreext_v01.html - if supportsNamestore { - buf.WriteString(``) - buf.WriteString(``) - buf.WriteString(extData["namestoreExt:subProduct"]) - buf.WriteString(``) - buf.WriteString(``) - } - - if supportsLaunch { - buf.WriteString(``) - buf.WriteString(``) - buf.WriteString(extData["launch:phase"]) - buf.WriteString(``) - buf.WriteString(``) - } - - if supportsNeulevel { - buf.WriteString(``) - buf.WriteString(``) - buf.WriteString(extData["neulevel:unspec"]) - buf.WriteString(``) - buf.WriteString(``) - } - - if len(feeURN) > 0 { - buf.WriteString(``) - feePhase := "" - if supportsFeePhase { - feePhase = fmt.Sprintf(" phase=%q", extData["fee:phase"]) - } - for _, domain := range domains { - switch feeURN { - case ExtFee09: // Version 0.9 changes the XML structure - buf.WriteString(``) - buf.WriteString(``) - xml.EscapeText(buf, []byte(domain)) - buf.WriteString(``) - buf.WriteString(fmt.Sprintf(`create`, feePhase)) - buf.WriteString(``) - case ExtFee11: // https://tools.ietf.org/html/draft-brown-epp-fees-07#section-5.1.1 - buf.WriteString(fmt.Sprintf(`create`, feePhase)) - case ExtFee21: // Version 0.21 changes the XML structure - buf.WriteString(``) - case ExtFee10: - buf.WriteString(``) - default: - buf.WriteString(``) - buf.WriteString(``) - xml.EscapeText(buf, []byte(domain)) - buf.WriteString(``) - buf.WriteString(fmt.Sprintf(`create`, feePhase)) - buf.WriteString(``) - } - } - buf.WriteString(``) - } - - if hasExtension { - buf.WriteString(``) - } - - buf.WriteString(xmlCommandSuffix) - - return buf.Bytes(), nil -} - -func encodePriceCheck(domains []string) ([]byte, error) { - buf := bytes.NewBufferString(xmlCommandPrefix) - buf.WriteString(``) - buf.WriteString(``) - for _, domain := range domains { - buf.WriteString(``) - xml.EscapeText(buf, []byte(domain)) - buf.WriteString(``) - } - buf.WriteString(``) - buf.WriteString(``) - // Extensions - buf.WriteString(``) - // ARI price extension - buf.WriteString(``) - buf.WriteString(``) - buf.WriteString(xmlCommandSuffix) - return buf.Bytes(), nil -} - -// DomainCheckResponse represents an EPP for a domain check. -type DomainCheckResponse struct { - Domain string - Checks []DomainCheck - Charges []DomainCharge -} - -// DomainCheck represents an EPP and associated extension data. -type DomainCheck struct { - Domain string - Reason string - Available bool -} - -// DomainCharge represents various EPP charge and fee extension data. -// FIXME: unpack into multiple types for different extensions. -type DomainCharge struct { - Domain string - Category string - CategoryName string -} - -func init() { - // Default EPP check data - path := "epp > response > resData > " + ObjDomain + " chkData" - scanResponse.MustHandleStartElement(path+">cd", func(c *xx.Context) error { - dcr := &c.Value.(*Response).DomainCheckResponse - dcr.Checks = append(dcr.Checks, DomainCheck{}) - return nil - }) - scanResponse.MustHandleCharData(path+">cd>name", func(c *xx.Context) error { - checks := c.Value.(*Response).DomainCheckResponse.Checks - check := &checks[len(checks)-1] - check.Domain = string(c.CharData) - check.Available = c.AttrBool("", "avail") - return nil - }) - scanResponse.MustHandleCharData(path+">cd>reason", func(c *xx.Context) error { - checks := c.Value.(*Response).DomainCheckResponse.Checks - check := &checks[len(checks)-1] - check.Reason = string(c.CharData) - return nil - }) - - // Scan charge-1.0 extension into Charges - path = "epp > response > extension > " + ExtCharge + " chkData" - scanResponse.MustHandleCharData(path+">cd>name", func(c *xx.Context) error { - c.Value.(*Response).DomainCheckResponse.Domain = string(c.CharData) - return nil - }) - scanResponse.MustHandleStartElement(path+">cd>set", func(c *xx.Context) error { - dcr := &c.Value.(*Response).DomainCheckResponse - dcr.Charges = append(dcr.Charges, DomainCharge{}) - return nil - }) - scanResponse.MustHandleCharData(path+">cd>set>category", func(c *xx.Context) error { - charges := c.Value.(*Response).DomainCheckResponse.Charges - charge := &charges[len(charges)-1] - charge.Domain = c.Value.(*Response).DomainCheckResponse.Domain - charge.Category = string(c.CharData) - charge.CategoryName = c.Attr("", "name") - return nil - }) - - path = "epp > response > extension > " + ExtFee05 + " chkData" - scanResponse.MustHandleStartElement(path+">cd", func(c *xx.Context) error { - dcr := &c.Value.(*Response).DomainCheckResponse - dcr.Charges = append(dcr.Charges, DomainCharge{}) - return nil - }) - scanResponse.MustHandleCharData(path+">cd>name", func(c *xx.Context) error { - charges := c.Value.(*Response).DomainCheckResponse.Charges - charge := &charges[len(charges)-1] - charge.Domain = string(c.CharData) - if c.AttrBool("", "premium") { - charge.Category = "premium" - } - return nil - }) - scanResponse.MustHandleCharData(path+">cd>class", func(c *xx.Context) error { - charges := c.Value.(*Response).DomainCheckResponse.Charges - charge := &charges[len(charges)-1] - charge.Category = string(c.CharData) - return nil - }) - scanResponse.MustHandleCharData(path+">cd>fee", func(c *xx.Context) error { - charges := c.Value.(*Response).DomainCheckResponse.Charges - charge := &charges[len(charges)-1] - charge.CategoryName = c.Attr("", "description") - return nil - }) - - path = "epp > response > extension > " + ExtFee06 + " chkData" - scanResponse.MustHandleStartElement(path+">cd", func(c *xx.Context) error { - dcr := &c.Value.(*Response).DomainCheckResponse - dcr.Charges = append(dcr.Charges, DomainCharge{}) - return nil - }) - scanResponse.MustHandleCharData(path+">cd>name", func(c *xx.Context) error { - charges := c.Value.(*Response).DomainCheckResponse.Charges - charge := &charges[len(charges)-1] - charge.Domain = string(c.CharData) - return nil - }) - scanResponse.MustHandleCharData(path+">cd>class", func(c *xx.Context) error { - charges := c.Value.(*Response).DomainCheckResponse.Charges - charge := &charges[len(charges)-1] - className := strings.ToLower(string(c.CharData)) - isDefault := strings.Contains(className, "default") - isNormal := strings.Contains(className, "normal") - isDiscount := strings.Contains(className, "discount") - //lint:ignore S1002 keep == false for clarity - if isDefault == false && isNormal == false && isDiscount == false { - charge.Category = "premium" - } - return nil - }) - - path = "epp > response > extension > " + ExtFee07 + " chkData" - scanResponse.MustHandleStartElement(path+">cd", func(c *xx.Context) error { - dcr := &c.Value.(*Response).DomainCheckResponse - dcr.Charges = append(dcr.Charges, DomainCharge{}) - return nil - }) - scanResponse.MustHandleCharData(path+">cd>name", func(c *xx.Context) error { - charges := c.Value.(*Response).DomainCheckResponse.Charges - charge := &charges[len(charges)-1] - charge.Domain = string(c.CharData) - return nil - }) - scanResponse.MustHandleCharData(path+">cd>class", func(c *xx.Context) error { - charges := c.Value.(*Response).DomainCheckResponse.Charges - charge := &charges[len(charges)-1] - charge.Category = string(c.CharData) - return nil - }) - - path = "epp > response > extension > " + ExtFee08 + " chkData" - scanResponse.MustHandleStartElement(path+">cd", func(c *xx.Context) error { - dcr := &c.Value.(*Response).DomainCheckResponse - dcr.Charges = append(dcr.Charges, DomainCharge{}) - return nil - }) - scanResponse.MustHandleCharData(path+">cd>name", func(c *xx.Context) error { - charges := c.Value.(*Response).DomainCheckResponse.Charges - charge := &charges[len(charges)-1] - charge.Domain = string(c.CharData) - return nil - }) - scanResponse.MustHandleCharData(path+">cd>class", func(c *xx.Context) error { - charges := c.Value.(*Response).DomainCheckResponse.Charges - charge := &charges[len(charges)-1] - if string(c.CharData) != "standard" { - charge.Category = "premium" - } - return nil - }) - - path = "epp > response > extension > " + ExtFee09 + " chkData" - scanResponse.MustHandleStartElement(path+">cd", func(c *xx.Context) error { - dcr := &c.Value.(*Response).DomainCheckResponse - dcr.Charges = append(dcr.Charges, DomainCharge{}) - return nil - }) - scanResponse.MustHandleCharData(path+">cd>objID", func(c *xx.Context) error { - charges := c.Value.(*Response).DomainCheckResponse.Charges - charge := &charges[len(charges)-1] - charge.Domain = string(c.CharData) - return nil - }) - scanResponse.MustHandleCharData(path+">cd>class", func(c *xx.Context) error { - charges := c.Value.(*Response).DomainCheckResponse.Charges - charge := &charges[len(charges)-1] - if string(c.CharData) != "standard" { - charge.Category = "premium" - } - return nil - }) - - path = "epp > response > extension > " + ExtFee11 + " chkData" - scanResponse.MustHandleStartElement(path+">cd", func(c *xx.Context) error { - dcr := &c.Value.(*Response).DomainCheckResponse - dcr.Charges = append(dcr.Charges, DomainCharge{}) - return nil - }) - scanResponse.MustHandleCharData(path+">cd>objID", func(c *xx.Context) error { - charges := c.Value.(*Response).DomainCheckResponse.Charges - charge := &charges[len(charges)-1] - charge.Domain = string(c.CharData) - return nil - }) - scanResponse.MustHandleCharData(path+">cd>class", func(c *xx.Context) error { - charges := c.Value.(*Response).DomainCheckResponse.Charges - charge := &charges[len(charges)-1] - charge.Category = string(c.CharData) - return nil - }) - - // Scan fee-0.21 phase and subphase into Charges Category and CategoryName, respectively - // FIXME: stop mangling fee extensions into charges - path = "epp > response > extension > " + ExtFee21 + " chkData > cd > command > fee" - scanResponse.MustHandleCharData(path, func(c *xx.Context) error { - if c.Parent.Attr("", "name") != "create" { - return nil - } - dcr := &c.Value.(*Response).DomainCheckResponse - check := &dcr.Checks[len(dcr.Checks)-1] - charge := DomainCharge{ - Domain: check.Domain, - Category: c.Parent.Attr("", "phase"), - CategoryName: c.Parent.Attr("", "subphase"), - } - dcr.Charges = append(dcr.Charges, charge) - return nil - }) - - // Scan price-1.1 extension into Charges - path = "epp > response > extension > " + ExtPrice + " chkData" - scanResponse.MustHandleStartElement(path+">cd", func(c *xx.Context) error { - dcr := &c.Value.(*Response).DomainCheckResponse - dcr.Charges = append(dcr.Charges, DomainCharge{}) - return nil - }) - scanResponse.MustHandleCharData(path+">cd>name", func(c *xx.Context) error { - charges := c.Value.(*Response).DomainCheckResponse.Charges - charge := &charges[len(charges)-1] - charge.Domain = string(c.CharData) - if c.AttrBool("", "premium") { - charge.Category = "premium" - } - return nil - }) - - // Scan neulevel-1.0 extension - path = "epp > response > extension > " + ExtNeulevel10 + " extension > unspec" - scanResponse.MustHandleCharData(path, func(c *xx.Context) error { - dcr := &c.Value.(*Response).DomainCheckResponse - if len(dcr.Checks) == 0 { - return nil - } - - check := &dcr.Checks[len(dcr.Checks)-1] - charge := DomainCharge{Domain: check.Domain} - data := string(c.CharData) - pairs := strings.Split(data, " ") - for _, pair := range pairs { - parts := strings.SplitN(pair, "=", 2) - if len(parts) == 2 && parts[0] == "TierName" { - charge.Category = parts[1] - break - } - } - dcr.Charges = append(dcr.Charges, charge) - - return nil - }) -} diff --git a/check_test.go b/check_test.go deleted file mode 100644 index ce03626..0000000 --- a/check_test.go +++ /dev/null @@ -1,807 +0,0 @@ -package epp - -import ( - "bytes" - "encoding/xml" - "testing" - - "github.com/nbio/st" -) - -func decoder(s string) *xml.Decoder { - return xml.NewDecoder(bytes.NewBufferString(s)) -} - -func TestConnCheck(t *testing.T) { - t.Skip("no live EPP tests in the test suite") - c, err := NewConn(nil) - st.Expect(t, err, nil) - st.Reject(t, c, nil) - dcr, err := c.CheckDomain("google.com") - st.Expect(t, err, nil) - st.Reject(t, dcr, nil) - st.Expect(t, len(dcr.Checks), 1) - st.Expect(t, dcr.Checks[0].Domain, "google.com") - st.Expect(t, dcr.Checks[0].Available, false) - - dcr, err = c.CheckDomain("dmnr-test-x759824vim-i2.com") - st.Expect(t, err, nil) - st.Reject(t, dcr, nil) - st.Expect(t, len(dcr.Checks), 1) - st.Expect(t, dcr.Checks[0].Domain, "dmnr-test-x759824vim-i2.com") - st.Expect(t, dcr.Checks[0].Available, true) - - dcr, err = c.CheckDomain("--dmnr-test--.com") - st.Reject(t, err, nil) - st.Expect(t, dcr, (*DomainCheckResponse)(nil)) -} - -func TestEncodeDomainCheck(t *testing.T) { - x, err := encodeDomainCheck(nil, []string{"hello.com", "foo.domains", "xn--ninja.net"}, nil) - st.Expect(t, err, nil) - st.Expect(t, string(x), ` -hello.comfoo.domainsxn--ninja.net`) - var v struct{} - err = xml.Unmarshal(x, &v) - st.Expect(t, err, nil) -} - -func TestEncodeDomainCheckLaunchPhase(t *testing.T) { - var greeting Greeting - greeting.Extensions = []string{ExtLaunch} - x, err := encodeDomainCheck(&greeting, []string{"hello.com", "foo.domains", "xn--ninja.net"}, map[string]string{"launch:phase": "claims"}) - st.Expect(t, err, nil) - st.Expect(t, string(x), ` -hello.comfoo.domainsxn--ninja.netclaims`) - var v struct{} - err = xml.Unmarshal(x, &v) - st.Expect(t, err, nil) -} - -func TestEncodeDomainCheckNeulevelUnspec(t *testing.T) { - var greeting Greeting - greeting.Extensions = []string{ExtNeulevel} - x, err := encodeDomainCheck(&greeting, []string{"hello.com", "foo.domains", "xn--ninja.net"}, map[string]string{"neulevel:unspec": "FeeCheck=Y"}) - st.Expect(t, err, nil) - st.Expect(t, string(x), ` -hello.comfoo.domainsxn--ninja.netFeeCheck=Y`) - var v struct{} - err = xml.Unmarshal(x, &v) - st.Expect(t, err, nil) -} - -func TestScanCheckDomainResponseWithCharge(t *testing.T) { - x := ` - - - - Command completed successfully - - - - - good.memorial - premium name - - - - - - - good.memorial - - premium - price - 100.00 - 100.00 - 100.00 - 50.00 - - - - - - 0000000000000002 - 83fa5767-5624-4be5-9e54-0b3a52f9de5b:1 - - -` - - var res Response - dcr := &res.DomainCheckResponse - - d := decoder(x) - err := IgnoreEOF(scanResponse.Scan(d, &res)) - st.Expect(t, err, nil) - st.Expect(t, len(dcr.Checks), 1) - st.Expect(t, dcr.Checks[0].Domain, "good.memorial") - st.Expect(t, dcr.Checks[0].Available, true) - st.Expect(t, dcr.Checks[0].Reason, "premium name") - st.Expect(t, len(dcr.Charges), 1) - st.Expect(t, dcr.Charges[0].Domain, "good.memorial") - st.Expect(t, dcr.Charges[0].Category, "premium") - st.Expect(t, dcr.Charges[0].CategoryName, "BBB+") -} -func TestScanCheckDomainResponseWithMultipleChargeSets(t *testing.T) { - x := ` - - - - Command completed successfully - - - - - good.memorial - premium name - - - - - - - good.memorial - - premium - price - 100.00 - 100.00 - 100.00 - 50.00 - - - earlyAccess - fee - 2500.00 - - - - - - 0000000000000002 - 83fa5767-5624-4be5-9e54-0b3a52f9de5b:1 - - -` - - var res Response - dcr := &res.DomainCheckResponse - - d := decoder(x) - err := IgnoreEOF(scanResponse.Scan(d, &res)) - st.Expect(t, err, nil) - st.Expect(t, len(dcr.Checks), 1) - st.Expect(t, dcr.Checks[0].Domain, "good.memorial") - st.Expect(t, dcr.Checks[0].Available, true) - st.Expect(t, dcr.Checks[0].Reason, "premium name") - st.Expect(t, len(dcr.Charges), 2) - st.Expect(t, dcr.Charges[0].Domain, "good.memorial") - st.Expect(t, dcr.Charges[0].Category, "premium") - st.Expect(t, dcr.Charges[0].CategoryName, "BBB+") - st.Expect(t, dcr.Charges[1].Domain, "good.memorial") - st.Expect(t, dcr.Charges[1].Category, "earlyAccess") - st.Expect(t, dcr.Charges[1].CategoryName, "") -} - -func TestScanCheckDomainResponseWithFee05(t *testing.T) { - x := ` - - - - Command completed successfully - - - - - good.space - - - - - - - good.space - USD - create - 1 - 100.00 - premium - - - - - 0000000000000002 - 83fa5767-5624-4be5-9e54-0b3a52f9de5b:1 - - -` - - var res Response - dcr := &res.DomainCheckResponse - - d := decoder(x) - err := IgnoreEOF(scanResponse.Scan(d, &res)) - st.Expect(t, err, nil) - st.Expect(t, len(dcr.Checks), 1) - st.Expect(t, dcr.Checks[0].Domain, "good.space") - st.Expect(t, dcr.Checks[0].Available, true) - st.Expect(t, dcr.Checks[0].Reason, "") - st.Expect(t, len(dcr.Charges), 1) - st.Expect(t, dcr.Charges[0].Domain, "good.space") - st.Expect(t, dcr.Charges[0].Category, "premium") - st.Expect(t, dcr.Charges[0].CategoryName, "Premium Registration Fee") -} - -func TestScanCheckDomainResponseWithFee06(t *testing.T) { - x := ` - - - - Command completed successfully - - - - - good.space - - - - - - - good.space - USD - create - 1 - 100.00 - SPACE Tier 1 - - - - - 0000000000000002 - 83fa5767-5624-4be5-9e54-0b3a52f9de5b:1 - - -` - - var res Response - dcr := &res.DomainCheckResponse - - d := decoder(x) - err := IgnoreEOF(scanResponse.Scan(d, &res)) - st.Expect(t, err, nil) - st.Expect(t, len(dcr.Checks), 1) - st.Expect(t, dcr.Checks[0].Domain, "good.space") - st.Expect(t, dcr.Checks[0].Available, true) - st.Expect(t, dcr.Checks[0].Reason, "") - st.Expect(t, len(dcr.Charges), 1) - st.Expect(t, dcr.Charges[0].Domain, "good.space") - st.Expect(t, dcr.Charges[0].Category, "premium") - st.Expect(t, dcr.Charges[0].CategoryName, "") -} - -func TestScanCheckDomainResponseWithFee07(t *testing.T) { - x := ` - - - - Command completed successfully - - - - - austin.green - - - - - - - austin.green - USD - create - 1 - 3500.00 - premium - - - - - 0000000000000002 - 83fa5767-5624-4be5-9e54-0b3a52f9de5b:1 - - -` - - var res Response - dcr := &res.DomainCheckResponse - - d := decoder(x) - err := IgnoreEOF(scanResponse.Scan(d, &res)) - st.Expect(t, err, nil) - st.Expect(t, len(dcr.Checks), 1) - st.Expect(t, dcr.Checks[0].Domain, "austin.green") - st.Expect(t, dcr.Checks[0].Available, true) - st.Expect(t, dcr.Checks[0].Reason, "") - st.Expect(t, len(dcr.Charges), 1) - st.Expect(t, dcr.Charges[0].Domain, "austin.green") - st.Expect(t, dcr.Charges[0].Category, "premium") - st.Expect(t, dcr.Charges[0].CategoryName, "") -} - -func TestScanCheckDomainResponseWithFee08(t *testing.T) { - x := ` - - - - Command completed successfully - - - - - crrc.yln - - - - - - - crrc.yln - CNY - create - 1 - 100.00 - premium - - - - - testnn-domain-check-f193d63b-1ab7-43bc-bc9d-4e835fb0fece - SERVER-4aafbfa9-cd31-4e89-b585-25a753d3c69a - - -` - - var res Response - dcr := &res.DomainCheckResponse - - d := decoder(x) - err := IgnoreEOF(scanResponse.Scan(d, &res)) - st.Expect(t, err, nil) - st.Expect(t, len(dcr.Checks), 1) - st.Expect(t, dcr.Checks[0].Domain, "crrc.yln") - st.Expect(t, dcr.Checks[0].Available, true) - st.Expect(t, dcr.Checks[0].Reason, "") - st.Expect(t, len(dcr.Charges), 1) - st.Expect(t, dcr.Charges[0].Domain, "crrc.yln") - st.Expect(t, dcr.Charges[0].Category, "premium") - st.Expect(t, dcr.Charges[0].CategoryName, "") -} - -func TestScanCheckDomainResponseWithFee09(t *testing.T) { - x := ` - - - - Command completed successfully - - - - - example.com - - - - - - - example.com - USD - create - 1 - 5.00 - 5.00 - premium-tier1 - - - - - ABC-12345 - 54322-XYZ - - -` - - var res Response - dcr := &res.DomainCheckResponse - - d := decoder(x) - err := IgnoreEOF(scanResponse.Scan(d, &res)) - st.Expect(t, err, nil) - st.Expect(t, len(dcr.Checks), 1) - st.Expect(t, dcr.Checks[0].Domain, "example.com") - st.Expect(t, dcr.Checks[0].Available, true) - st.Expect(t, dcr.Checks[0].Reason, "") - st.Expect(t, len(dcr.Charges), 1) - st.Expect(t, dcr.Charges[0].Domain, "example.com") - st.Expect(t, dcr.Charges[0].Category, "premium") - st.Expect(t, dcr.Charges[0].CategoryName, "") -} - -func TestScanCheckDomainResponseWithFee11(t *testing.T) { - x := ` - - - - Command completed successfully - - - - - example.com - - - - - - - example.com - USD - create - 1 - 50.00 - premium - - - - - ABC-12345 - 54322-XYZ - - -` - - var res Response - dcr := &res.DomainCheckResponse - - d := decoder(x) - err := IgnoreEOF(scanResponse.Scan(d, &res)) - st.Expect(t, err, nil) - st.Expect(t, len(dcr.Checks), 1) - st.Expect(t, dcr.Checks[0].Domain, "example.com") - st.Expect(t, dcr.Checks[0].Available, true) - st.Expect(t, dcr.Checks[0].Reason, "") - st.Expect(t, len(dcr.Charges), 1) - st.Expect(t, dcr.Charges[0].Domain, "example.com") - st.Expect(t, dcr.Charges[0].Category, "premium") - st.Expect(t, dcr.Charges[0].CategoryName, "") -} - -func TestScanCheckDomainResponseWithFee21(t *testing.T) { - x := ` - - - - Command completed successfully - - - - - example.com - - - - - - EUR - - example.com - - 1 - 25.00 - - - the requested launch phase is not suitable for the domain - - - - - - 1501792511080-81912 - - -` - - var res Response - dcr := &res.DomainCheckResponse - - d := decoder(x) - err := IgnoreEOF(scanResponse.Scan(d, &res)) - st.Expect(t, err, nil) - st.Expect(t, len(dcr.Checks), 1) - st.Expect(t, dcr.Checks[0].Domain, "example.com") - st.Expect(t, dcr.Checks[0].Available, true) - st.Expect(t, dcr.Checks[0].Reason, "") - st.Expect(t, len(dcr.Charges), 1) - st.Expect(t, dcr.Charges[0].Domain, "example.com") - st.Expect(t, dcr.Charges[0].Category, "open") - st.Expect(t, dcr.Charges[0].CategoryName, "") -} - -func TestScanCheckDomainResponseWithFee21Premium(t *testing.T) { - x := ` - - - - Command completed successfully - - - - - example.com - not registrable in this phase - - - - - - EUR - - example.com - - the requested launch phase is not suitable for the domain - - - 1 - 800.00 - - - - - - 1501792511080-81912 - - -` - - var res Response - dcr := &res.DomainCheckResponse - - d := decoder(x) - err := IgnoreEOF(scanResponse.Scan(d, &res)) - st.Expect(t, err, nil) - st.Expect(t, len(dcr.Checks), 1) - st.Expect(t, dcr.Checks[0].Domain, "example.com") - st.Expect(t, dcr.Checks[0].Available, false) - st.Expect(t, dcr.Checks[0].Reason, "not registrable in this phase") - st.Expect(t, len(dcr.Charges), 1) - st.Expect(t, dcr.Charges[0].Domain, "example.com") - st.Expect(t, dcr.Charges[0].Category, "custom") - st.Expect(t, dcr.Charges[0].CategoryName, "open-1000") -} - -func TestScanCheckDomainResponseWithFee10(t *testing.T) { - x := ` - - - - Command completed successfully - - - - - example.sport - - - - - - USD - - example.sport - standard - - 1 - 300.00 - - - - - - 1612577898803-269276 - - - ` - - var res Response - dcr := &res.DomainCheckResponse - - d := decoder(x) - err := IgnoreEOF(scanResponse.Scan(d, &res)) - st.Expect(t, err, nil) - st.Expect(t, len(dcr.Checks), 1) - st.Expect(t, dcr.Checks[0].Domain, "example.sport") - st.Expect(t, dcr.Checks[0].Available, true) - st.Expect(t, dcr.Checks[0].Reason, "") -} - -func TestScanCheckDomainResponseWithPremiumAttribute(t *testing.T) { - x := ` - - - - Command completed successfully - - - - - zero.work - - - - - - - zero.work - USD - create - 1 - 500.000 - - - - - 14470834306141 - - -` - - var res Response - dcr := &res.DomainCheckResponse - - d := decoder(x) - err := IgnoreEOF(scanResponse.Scan(d, &res)) - st.Expect(t, err, nil) - st.Expect(t, len(dcr.Checks), 1) - st.Expect(t, dcr.Checks[0].Domain, "zero.work") - st.Expect(t, dcr.Checks[0].Available, true) - st.Expect(t, dcr.Checks[0].Reason, "") - st.Expect(t, len(dcr.Charges), 1) - st.Expect(t, dcr.Charges[0].Domain, "zero.work") - st.Expect(t, dcr.Charges[0].Category, "premium") - st.Expect(t, dcr.Charges[0].CategoryName, "Registration Fee") -} - -func TestScanCheckDomainResponseNeulevelExtension(t *testing.T) { - x := ` - - - - Command completed successfully - - - - - 420.earth - - - - - - TierName=EARTH_Tier3 AnnualTierPrice=120.00 - - - - 0000000000000002 - 83fa5767-5624-4be5-9e54-0b3a52f9de5b:1 - - -` - - var res Response - dcr := &res.DomainCheckResponse - - d := decoder(x) - err := IgnoreEOF(scanResponse.Scan(d, &res)) - st.Expect(t, err, nil) - st.Expect(t, len(dcr.Checks), 1) - st.Expect(t, dcr.Checks[0].Domain, "420.earth") - st.Expect(t, dcr.Checks[0].Available, true) - st.Expect(t, dcr.Checks[0].Reason, "") - st.Expect(t, len(dcr.Charges), 1) - st.Expect(t, dcr.Charges[0].Domain, "420.earth") - st.Expect(t, dcr.Charges[0].Category, "EARTH_Tier3") - st.Expect(t, dcr.Charges[0].CategoryName, "") -} - -func TestScanCheckDomainResponsePriceExtension(t *testing.T) { - x := ` - - - - Command completed successfully - - - - - foundations.build - 1 - 1500 - 1500 - 40 - 1500 - - - - - aaa39bf9-12dd-4810-bdb2-98f629cfbbbb - - -` - - var res Response - dcr := &res.DomainCheckResponse - - d := decoder(x) - err := IgnoreEOF(scanResponse.Scan(d, &res)) - st.Expect(t, err, nil) - st.Expect(t, len(dcr.Checks), 0) - st.Expect(t, len(dcr.Charges), 1) - st.Expect(t, dcr.Charges[0].Domain, "foundations.build") - st.Expect(t, dcr.Charges[0].Category, "premium") - st.Expect(t, dcr.Charges[0].CategoryName, "") -} - -func BenchmarkEncodeDomainCheck(b *testing.B) { - domains := []string{"hello.com"} - for i := 0; i < b.N; i++ { - encodeDomainCheck(nil, domains, nil) - } -} - -func BenchmarkScanDomainCheckResponse(b *testing.B) { - x := ` - - - - Command completed successfully - - - - - good.memorial - premium name - - - - - - - good.memorial - - premium - price - 100.00 - 100.00 - 100.00 - 50.00 - - - - - - 0000000000000002 - 83fa5767-5624-4be5-9e54-0b3a52f9de5b:1 - - -` - - for i := 0; i < b.N; i++ { - b.StopTimer() - d := decoder(x) - b.StartTimer() - var res Response - scanResponse.Scan(d, &res) - } -} diff --git a/cmd/epp/epp.go b/cmd/epp/epp.go deleted file mode 100644 index a698856..0000000 --- a/cmd/epp/epp.go +++ /dev/null @@ -1,200 +0,0 @@ -package main - -import ( - "crypto/tls" - "crypto/x509" - "flag" - "fmt" - "io/ioutil" - "net" - "net/url" - "os" - "time" - - "golang.org/x/net/proxy" - - "github.com/domainr/epp" - "github.com/wsxiaoys/terminal/color" -) - -func main() { - var uri, addr, user, pass, proxyAddr, crtPath, caPath, keyPath string - var useTLS, batch, verbose bool - - flag.StringVar(&uri, "url", "", "EPP server URL, e.g. epp://user:pass@api.1api.net:700") - flag.StringVar(&addr, "addr", "", "EPP server address (HOST:PORT)") - flag.StringVar(&user, "u", "", "EPP user name") - flag.StringVar(&pass, "p", "", "EPP password") - flag.BoolVar(&useTLS, "tls", true, "use TLS") - flag.StringVar(&proxyAddr, "proxy", "", "SOCKS5 proxy address (HOST:PORT)") - flag.StringVar(&crtPath, "cert", "", "path to SSL certificate") - flag.StringVar(&keyPath, "key", "", "path to SSL private key") - flag.StringVar(&caPath, "ca", "", "path to SSL certificate authority") - flag.BoolVar(&batch, "batch", false, "check all domains in a single EPP command") - flag.BoolVar(&verbose, "v", false, "enable verbose debug logging") - - flag.Usage = func() { - fmt.Fprintf(os.Stderr, "Usage: %s [arguments] \n\nAvailable arguments:\n", os.Args[0]) - flag.PrintDefaults() - os.Exit(1) - } - flag.Parse() - if len(flag.Args()) == 0 { - flag.Usage() - } - - if verbose { - epp.DebugLogger = os.Stderr - } - - domains := make([]string, len(flag.Args())) - for i, arg := range flag.Args() { - domains[i] = arg // FIXME: convert unicode to Punycode? - } - - // Parse URL - if uri != "" { - addr, user, pass = parseURL(uri) - } - host, _, err := net.SplitHostPort(addr) - if err != nil { - host = addr - } - - // Set up TLS - cfg := &tls.Config{ - InsecureSkipVerify: true, - ServerName: host, - } - - // Load certificates - if caPath != "" { - color.Fprintf(os.Stderr, "Loading CA certificate from %s\n", caPath) - ca, err := ioutil.ReadFile(caPath) - fatalif(err) - cfg.RootCAs = x509.NewCertPool() - cfg.RootCAs.AppendCertsFromPEM(ca) - } - - if crtPath != "" && keyPath != "" { - color.Fprintf(os.Stderr, "Loading certificate %s and key %s\n", crtPath, keyPath) - crt, err := tls.LoadX509KeyPair(crtPath, keyPath) - fatalif(err) - cfg.Certificates = append(cfg.Certificates, crt) - // cfg.BuildNameToCertificate() - useTLS = true - } - - // Use TLS? - if !useTLS { - cfg = nil - } - - // Dial - start := time.Now() - var conn net.Conn - if proxyAddr != "" { - color.Fprintf(os.Stderr, "Connecting to %s via proxy %s\n", addr, proxyAddr) - dialer, err := proxy.SOCKS5("tcp", proxyAddr, nil, &net.Dialer{}) - fatalif(err) - conn, err = dialer.Dial("tcp", addr) - } else { - color.Fprintf(os.Stderr, "Connecting to %s\n", addr) - conn, err = net.Dial("tcp", addr) - } - fatalif(err) - - // TLS - if useTLS { - color.Fprintf(os.Stderr, "Establishing TLS connection\n") - tc := tls.Client(conn, cfg) - err = tc.Handshake() - fatalif(err) - conn = tc - } - - // EPP - color.Fprintf(os.Stderr, "Performing EPP handshake\n") - c, err := epp.NewConn(conn) - fatalif(err) - color.Fprintf(os.Stderr, "Logging in as %s...\n", user) - err = c.Login(user, pass, "") - fatalif(err) - - // Check - start = time.Now() - if batch { - dc, err := c.CheckDomain(domains...) - logif(err) - printDCR(dc) - } else { - for _, domain := range domains { - dc, err := c.CheckDomain(domain) - logif(err) - printDCR(dc) - } - } - qdur := time.Since(start) - - color.Fprintf(os.Stderr, "@{.}Query: %s Avg: %s\n", qdur, qdur/time.Duration(len(domains))) -} - -func parseURL(uri string) (addr, user, pass string) { - u, err := url.Parse(uri) - if err != nil { - return - } - host, port, err := net.SplitHostPort(u.Host) - if host == "" { - host = u.Host - } - if port == "" { - port = DefaultEPPPort - } - addr = net.JoinHostPort(host, port) - if ui := u.User; ui != nil { - user = ui.Username() - pass, _ = ui.Password() - } - return -} - -// DefaultEPPPort is the default TCP port for the EPP protocol. -const DefaultEPPPort = "700" - -func logif(err error) bool { - if err != nil { - color.Fprintf(os.Stderr, "@{r}%s\n", err) - return true - } - return false -} - -func fatalif(err error) { - if logif(err) { - color.Fprintf(os.Stderr, "@{r}EXITING\n") - os.Exit(1) - } -} - -func printDCR(dcr *epp.DomainCheckResponse) { - if dcr == nil { - return - } - av := make(map[string]bool) - for _, c := range dcr.Checks { - av[c.Domain] = c.Available - if c.Available { - color.Printf("@{g}%s\tavail=%t\treason=%q\n", c.Domain, c.Available, c.Reason) - } else { - color.Printf("@{y}%s\tavail=%t\treason=%q\n", c.Domain, c.Available, c.Reason) - } - } - for _, c := range dcr.Charges { - if av[c.Domain] { - color.Printf("@{g}%s\tcategory=%s\tname=%q\n", c.Domain, c.Category, c.CategoryName) - } else { - color.Printf("@{y}%s\tcategory=%s\tname=%q\n", c.Domain, c.Category, c.CategoryName) - } - } -} diff --git a/config.go b/config.go new file mode 100644 index 0000000..e829714 --- /dev/null +++ b/config.go @@ -0,0 +1,80 @@ +package epp + +import ( + "github.com/domainr/epp/internal/schema/epp" +) + +// Config describes an EPP client or server configuration, including +// EPP objects and extensions used for a connection. +type Config struct { + // Supported EPP version(s). Typically this should not be set + // by either a client or server. If nil, this will default to + // []string{"1.0"} (currently the only supported version). + Versions []string + + // BCP 47 language code(s) for human-readable messages. + // For clients, this describes the desired language(s) in preferred order. + // If the server does not support any of the client’s preferred languages, + // the first language advertised by the server will be selected. + // For servers, this describes its supported language(s). + // If nil, []string{"en"} will be used. + Languages []string + + // Namespace URIs of EPP objects supported by a client or server. + // For clients, this describes the object type(s) the client wants to access. + // For servers, this describes the object type(s) the server allows clients to access. + // If nil, a reasonable set of defaults will be used. + Objects []string + + // EPP extension URIs supported by a client or server. + // For clients, this is a list of extensions(s) the client wants to use in preferred order. + // If nil, a client will use the highest version of each supported extension advertised by the server. + // For servers, this is an advertised list of supported extension(s). + // If nil, a server will use a reasonable set of defaults. + Extensions []string + + // ForcedExtensions contains one or more EPP extension URIs to be used + // by a client or server, whether or not the peer indicates support for + // it. This is used as a workaround for EPP servers that incorrectly + // advertise the extensions they support. This value should typically be + // left nil. This will always be nil when read from a peer. + ForcedExtensions []string + + // TransactionID, if not nil, returns unique values used for client or server transaction IDs. + // If nil, a sequential transaction ID with a random prefix will be used. + // The function must be safe to call from multiple goroutines. + TransactionID func() string +} + +func configFromGreeting(g *epp.Greeting) Config { + c := Config{} + // TODO: should epp.Greeting have getter and setter methods to access deeply-nested data? + if g.ServiceMenu != nil { + c.Versions = copySlice(g.ServiceMenu.Versions) + c.Languages = copySlice(g.ServiceMenu.Languages) + c.Objects = copySlice(g.ServiceMenu.Objects) + if g.ServiceMenu.ServiceExtension != nil { + c.Extensions = copySlice(g.ServiceMenu.ServiceExtension.Extensions) + } + } + return c +} + +// Copy deep copy of c. +func (c Config) Copy() Config { + c.Versions = copySlice(c.Versions) + c.Languages = copySlice(c.Languages) + c.Objects = copySlice(c.Objects) + c.Extensions = copySlice(c.Extensions) + c.ForcedExtensions = copySlice(c.ForcedExtensions) + return c +} + +func copySlice(s []string) []string { + if s == nil { + return nil + } + dst := make([]string, len(s)) + copy(dst, s) + return dst +} diff --git a/conn.go b/conn.go index 5dc5daf..503dab7 100644 --- a/conn.go +++ b/conn.go @@ -2,158 +2,151 @@ package epp import ( "encoding/binary" - "encoding/xml" "io" "net" "sync" - "time" ) -// IgnoreEOF returns err unless err == io.EOF, -// in which case it returns nil. -func IgnoreEOF(err error) error { - if err == io.EOF { - return nil - } - return err -} +// Conn is a generic connection that can read and write EPP data units. +// Multiple goroutines may invoke methods on a Conn simultaneously. +type Conn interface { + // ReadDataUnit reads a single EPP data unit, returning the payload bytes or an error. + ReadDataUnit() ([]byte, error) -// Conn represents a single connection to an EPP server. -// Reads and writes are serialized, so it is safe for concurrent use. -type Conn struct { - // Conn is the underlying net.Conn (usually a TLS connection). - net.Conn + // WriteDataUnit writes a single EPP data unit, returning any error. + WriteDataUnit([]byte) error - // Timeout defines the timeout for network operations. - // It must be set at initialization. Changing it after - // a connection is already opened will have no effect. - Timeout time.Duration + // Close closes the connection. + Close() error - // m protects Greeting and LoginResult. - m sync.Mutex + // LocalAddr returns the local network address, if any. + LocalAddr() net.Addr - // Greeting holds the last received greeting message from the server, - // indicating server name, status, data policy and capabilities. - // - // Deprecated: This field is written to upon opening a new EPP connection and should not be modified. - Greeting + // RemoteAddr returns the remote network address, if any. + RemoteAddr() net.Addr +} - // LoginResult holds the last received login response message's Result - // from the server, in which some servers might include diagnostics such - // as connection count limits. - // - // Deprecated: this field is written to by the Login method but otherwise is not used by this package. - LoginResult Result +// Pipe implements Conn using an io.Reader and an io.Writer. +type Pipe struct { + // R is from by ReadDataUnit. + R io.Reader - // mRead synchronizes connection reads. - mRead sync.Mutex + // W is written to by WriteDataUnit. + W io.Writer - // mWrite synchronizes connection writes. - mWrite sync.Mutex + r sync.Mutex + w sync.Mutex +} + +var _ Conn = &Pipe{} - done chan struct{} +// ReadDataUnit reads a single EPP data unit from t, returning the payload bytes or an error. +func (t *Pipe) ReadDataUnit() ([]byte, error) { + t.r.Lock() + defer t.r.Unlock() + return ReadDataUnit(t.R) } -// NewConn initializes an epp.Conn from a net.Conn and performs the EPP -// handshake. It reads and stores the initial EPP message. -// https://tools.ietf.org/html/rfc5730#section-2.4 -func NewConn(conn net.Conn) (*Conn, error) { - return NewTimeoutConn(conn, 0) +// WriteDataUnit writes a single EPP data unit to t or returns an error. +func (t *Pipe) WriteDataUnit(data []byte) error { + t.w.Lock() + defer t.w.Unlock() + return WriteDataUnit(t.W, data) } -// NewTimeoutConn initializes an epp.Conn like NewConn, limiting the duration of network -// operations on conn using Set(Read|Write)Deadline. -func NewTimeoutConn(conn net.Conn, timeout time.Duration) (*Conn, error) { - c := &Conn{ - Conn: conn, - Timeout: timeout, - done: make(chan struct{}), +// Close attempts to close both the underlying reader and writer. +// It will return the first error encountered. +func (t *Pipe) Close() error { + var rerr, werr error + if c, ok := t.R.(io.Closer); ok { + rerr = c.Close() + } + if r, ok := t.W.(io.Reader); ok && r == t.R { + return rerr + } + if c, ok := t.W.(io.Closer); ok { + werr = c.Close() } - g, err := c.readGreeting() - if err == nil { - c.m.Lock() - c.Greeting = g - c.m.Unlock() + if rerr != nil { + return rerr } - return c, err + return werr } -// Close sends an EPP command and closes the connection c. -func (c *Conn) Close() error { - select { - case <-c.done: - return net.ErrClosed - default: +// LocalAddr attempts to return the local address of p. +// If p.R implements LocalAddr, it will be called. +// Otherwise, LocalAddr will return nil. +func (p *Pipe) LocalAddr() net.Addr { + if a, ok := p.R.(interface{ LocalAddr() net.Addr }); ok { + return a.LocalAddr() } - c.Logout() - close(c.done) - return c.Conn.Close() + return nil } -// writeRequest writes a single EPP request (x) for writing on c. -// writeRequest can be called from multiple goroutines. -func (c *Conn) writeRequest(x []byte) error { - c.mWrite.Lock() - defer c.mWrite.Unlock() - if c.Timeout > 0 { - c.Conn.SetWriteDeadline(time.Now().Add(c.Timeout)) +// RemoteAddr attempts to return the remote address of p. +// If p.W implements RemoteAddr, it will be called. +// Otherwise, RemoteAddr will return nil. +func (p *Pipe) RemoteAddr() net.Addr { + if a, ok := p.W.(interface{ RemoteAddr() net.Addr }); ok { + return a.RemoteAddr() } - return writeDataUnit(c.Conn, x) + return nil } -// readResponse dequeues and returns a EPP response from c. -// It returns an error if the EPP response contains an error Result. -// readResponse can be called from multiple goroutines. -func (c *Conn) readResponse() (*Response, error) { - c.mRead.Lock() - defer c.mRead.Unlock() - if c.Timeout > 0 { - c.Conn.SetReadDeadline(time.Now().Add(c.Timeout)) - } - n, err := readDataUnitHeader(c.Conn) +// NetConn implements Conn using a net.Conn. +type NetConn struct { + net.Conn + r sync.Mutex + w sync.Mutex +} + +var _ Conn = &NetConn{} + +// ReadDataUnit reads a single EPP data unit from t, returning the payload or an error. +func (t *NetConn) ReadDataUnit() ([]byte, error) { + t.r.Lock() + defer t.r.Unlock() + return ReadDataUnit(t.Conn) +} + +// WriteDataUnit writes a single EPP data unit to t or returns an error. +func (t *NetConn) WriteDataUnit(p []byte) error { + t.w.Lock() + defer t.w.Unlock() + return WriteDataUnit(t.Conn, p) +} + +// ReadDataUnit reads a single EPP data unit from r, returning the payload or an error. +// An EPP data unit is prefixed with 32-bit header specifying the total size +// of the data unit (message + 4 byte header), in network (big-endian) order. +// See http://www.ietf.org/rfc/rfc4934.txt for more information. +func ReadDataUnit(r io.Reader) ([]byte, error) { + var n uint32 + err := binary.Read(r, binary.BigEndian, &n) if err != nil { return nil, err } - r := io.LimitedReader{R: c.Conn, N: int64(n)} - res := &Response{} - err = IgnoreEOF(scanResponse.Scan(xml.NewDecoder(&r), res)) - if err != nil { - return res, err - } - if res.Result.IsError() { - return res, &res.Result + // An EPP data unit size includes the 4 byte header. + // See https://tools.ietf.org/html/rfc5734#section-4. + if n < 4 { + return nil, io.ErrUnexpectedEOF } - return res, err + n -= 4 + p := make([]byte, n) + _, err = io.ReadAtLeast(r, p, int(n)) + return p, err } -// writeDataUnit writes x to w. +// WriteDataUnit writes a single EPP data unit to w. // Bytes written are prefixed with 32-bit header specifying the total size // of the data unit (message + 4 byte header), in network (big-endian) order. -// http://www.ietf.org/rfc/rfc4934.txt -func writeDataUnit(w io.Writer, x []byte) error { - logXML("<-- WRITE DATA UNIT -->", x) - s := uint32(4 + len(x)) +// See http://www.ietf.org/rfc/rfc4934.txt for more information. +func WriteDataUnit(w io.Writer, p []byte) error { + s := uint32(4 + len(p)) err := binary.Write(w, binary.BigEndian, s) if err != nil { return err } - _, err = w.Write(x) + _, err = w.Write(p) return err } - -// readDataUnitHeader reads a single EPP data unit header from r, returning the payload size or an error. -// An EPP data unit is prefixed with 32-bit header specifying the total size -// of the data unit (message + 4 byte header), in network (big-endian) order. -// http://www.ietf.org/rfc/rfc4934.txt -func readDataUnitHeader(r io.Reader) (uint32, error) { - var n uint32 - err := binary.Read(r, binary.BigEndian, &n) - if err != nil { - return 0, err - } - if n < 4 { - return 0, io.ErrUnexpectedEOF - } - // https://tools.ietf.org/html/rfc5734#section-4 - return n - 4, err -} diff --git a/conn_test.go b/conn_test.go deleted file mode 100644 index c008277..0000000 --- a/conn_test.go +++ /dev/null @@ -1,97 +0,0 @@ -package epp - -import ( - "bytes" - "net" - "sync" - "testing" - - "github.com/nbio/st" -) - -type localServer struct { - lnmu sync.RWMutex - net.Listener - done chan bool // signal that indicates server stopped -} - -func (ls *localServer) buildup(handler func(*localServer, net.Listener)) error { - go func() { - handler(ls, ls.Listener) - close(ls.done) - }() - return nil -} - -func (ls *localServer) teardown() { - ls.lnmu.Lock() - defer ls.lnmu.Unlock() - if ls.Listener != nil { - ls.Listener.Close() - <-ls.done - ls.Listener = nil - } -} - -func newLocalServer() (*localServer, error) { - ln, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - return nil, err - } - return &localServer{Listener: ln, done: make(chan bool)}, nil -} - -func TestNewConn(t *testing.T) { - ls, err := newLocalServer() - st.Assert(t, err, nil) - defer ls.teardown() - ls.buildup(func(ls *localServer, ln net.Listener) { - conn, err := ls.Accept() - st.Assert(t, err, nil) - // Respond with greeting - err = writeDataUnit(conn, []byte(testXMLGreeting)) - st.Assert(t, err, nil) - // Read logout message - _, err = readDataUnitHeader(conn) - st.Assert(t, err, nil) - // Close connection - err = conn.Close() - st.Assert(t, err, nil) - }) - nc, err := net.Dial(ls.Listener.Addr().Network(), ls.Listener.Addr().String()) - st.Assert(t, err, nil) - c, err := NewConn(nc) - st.Assert(t, err, nil) - st.Reject(t, c, nil) - st.Reject(t, c.Greeting.ServerName, "") - err = c.Close() - st.Expect(t, err, nil) -} - -func TestDeleteRange(t *testing.T) { - v := deleteRange([]byte(``), []byte(``)) - st.Expect(t, string(v), ``) - - v = deleteRange([]byte(``), []byte(``), []byte(`o>`)) - st.Expect(t, string(v), ``) -} - -func deleteBufferRange(buf *bytes.Buffer, pfx, sfx []byte) { - v := deleteRange(buf.Bytes(), pfx, sfx) - buf.Truncate(len(v)) -} - -func deleteRange(s, pfx, sfx []byte) []byte { - start := bytes.Index(s, pfx) - if start < 0 { - return s - } - end := bytes.Index(s[start+len(pfx):], sfx) - if end < 0 { - return s - } - end += start + len(pfx) + len(sfx) - size := len(s) - (end - start) - copy(s[start:size], s[end:]) - return s[:size] -} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..a26e711 --- /dev/null +++ b/errors.go @@ -0,0 +1,32 @@ +package epp + +import ( + "errors" + "fmt" +) + +// ErrClosedConnection indicates a read or write operation on a closed connection. +var ErrClosedConnection = errors.New("epp: operation on closed connection") + +// ErrUnexpectedHello indicates an EPP message contained an unexpected element. +var ErrUnexpectedHello = errors.New("epp: unexpected ") + +// ErrUnexpectedCommand indicates an EPP message contained a element. +var ErrUnexpectedCommand = errors.New("epp: unexpected ") + +// ErrNoResponse indicates an EPP message did not contain an expected element. +var ErrNoResponse = errors.New("epp: missing ") + +// ErrUnexpectedResponse indicates an EPP message contained an unexpected element. +var ErrUnexpectedResponse = errors.New("epp: unexpected ") + +// ErrNoTransactionID indicates an EPP message did not contain an expected transaction ID. +var ErrNoTransactionID = errors.New("epp: missing transaction ID") + +// TransactionIDError indicates an invalid transaction ID. +type TransactionIDError string + +// Error implements the error interface. +func (err TransactionIDError) Error() string { + return fmt.Sprintf("epp: invalid transaction ID: %q", string(err)) +} diff --git a/go.mod b/go.mod index 782802f..478c962 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,14 @@ module github.com/domainr/epp -go 1.15 +go 1.17 require ( github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 - github.com/nbio/xx v0.0.0-20171204172743-d97a23099bf2 + github.com/nbio/xml v0.0.0-20211203233926-213e87217328 + github.com/nbio/xx v0.0.0-20211016162247-522295b80baa + github.com/rickb777/date v1.16.1 github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0 - golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3 + golang.org/x/net v0.0.0-20211123203042-d83791d6bcd9 ) + +require github.com/rickb777/plural v1.3.0 // indirect diff --git a/go.sum b/go.sum index 26944a2..25c70c7 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,71 @@ +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= -github.com/nbio/xx v0.0.0-20171204172743-d97a23099bf2 h1:QiO0Kf91RWZPOEULlAVLTbCHZ/meDfIRHnf8Sa7NZok= -github.com/nbio/xx v0.0.0-20171204172743-d97a23099bf2/go.mod h1:bZz+wNArPTHg7xRNUHbGCS5+IEsB+29q1RoWJhix1rY= +github.com/nbio/xml v0.0.0-20211014203712-dccdac7ed502/go.mod h1:Gxm91uHcm2tGopMmOefUHM6koiUsWvp/4FwYIJ9jo/k= +github.com/nbio/xml v0.0.0-20211201183319-d6ca022aaad1 h1:U95PwWJmrD1iCwWSv2ZxFtyOfAkE1aLpoO7XHvcoazM= +github.com/nbio/xml v0.0.0-20211201183319-d6ca022aaad1/go.mod h1:Gxm91uHcm2tGopMmOefUHM6koiUsWvp/4FwYIJ9jo/k= +github.com/nbio/xml v0.0.0-20211203233926-213e87217328 h1:r2qjrHwFZbADThD/zsB6ARdzfkVyyseidffIOPlyHkU= +github.com/nbio/xml v0.0.0-20211203233926-213e87217328/go.mod h1:Gxm91uHcm2tGopMmOefUHM6koiUsWvp/4FwYIJ9jo/k= +github.com/nbio/xx v0.0.0-20211016162247-522295b80baa h1:97k42e9VIFpGssByqNkbgCN2aShfcs9OC4NSzkatpEI= +github.com/nbio/xx v0.0.0-20211016162247-522295b80baa/go.mod h1:Ot5GE0TNFpVT69k/IFalGXyinlUN9FodpiM8FbqGU8Y= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.5 h1:7n6FEkpFmfCoo2t+YYqXH0evK+a9ICQz0xcAy9dYcaQ= +github.com/onsi/gomega v1.10.5/go.mod h1:gza4q3jKQJijlu05nKWRCW/GavJumGt8aNRxWg7mt48= +github.com/rickb777/date v1.16.1 h1:nUx7FrnRLxwj4QpbuHOz7RRcnEyFOiXnZxdC2lx0f8c= +github.com/rickb777/date v1.16.1/go.mod h1:QwU+l0bIHSFsMQH12voxZbC531J+lM3A/ZFq5gku8F8= +github.com/rickb777/plural v1.3.0 h1:cN3M4IcJCGiGpa92S3xJgiBQfqGDFj7J8JyObugVwAU= +github.com/rickb777/plural v1.3.0/go.mod h1:xyHbelv4YvJE51gjMnHvk+U2e9zIysg6lTnSQK8XUYA= github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0 h1:3UeQBvD0TFrlVjOeLOBz+CPAI8dnbqNSVwUwRrkp7vQ= github.com/wsxiaoys/terminal v0.0.0-20160513160801-0940f3fc43a0/go.mod h1:IXCdmsXIht47RaVFLEdVnh1t+pgYtTAhQGj73kz+2DM= -golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3 h1:czFLhve3vsQetD6JOJ8NZZvGQIXlnN3/yXxbT6/awxI= -golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211123203042-d83791d6bcd9 h1:0qxwC5n+ttVOINCBeRHO0nq9X7uy8SDsPoi5OaCdIEI= +golang.org/x/net v0.0.0-20211123203042-d83791d6bcd9/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/greeting.go b/greeting.go deleted file mode 100644 index 78d93ea..0000000 --- a/greeting.go +++ /dev/null @@ -1,141 +0,0 @@ -package epp - -import ( - "encoding/xml" - - "github.com/nbio/xx" -) - -// Hello sends a command to request a from the EPP server. -func (c *Conn) Hello() error { - err := c.writeRequest(xmlHello) - if err != nil { - return err - } - _, err = c.readGreeting() - return err -} - -var xmlHello = []byte(xml.Header + startEPP + `` + endEPP) - -// Greeting is an EPP response that represents server status and capabilities. -// https://tools.ietf.org/html/rfc5730#section-2.4 -type Greeting struct { - ServerName string `xml:"svID"` - Versions []string `xml:"svcMenu>version"` - Languages []string `xml:"svcMenu>lang"` - Objects []string `xml:"svcMenu>objURI"` - Extensions []string `xml:"svcMenu>svcExtension>extURI,omitempty"` -} - -// SupportsObject returns true if the EPP server supports -// the object specified by uri. -func (g *Greeting) SupportsObject(uri string) bool { - if g == nil { - return false - } - for _, v := range g.Objects { - if v == uri { - return true - } - } - return false -} - -// SupportsExtension returns true if the EPP server supports -// the extension specified by uri. -func (g *Greeting) SupportsExtension(uri string) bool { - if g == nil { - return false - } - for _, v := range g.Extensions { - if v == uri { - return true - } - } - return false -} - -// EPP extension URNs -const ( - ObjDomain = "urn:ietf:params:xml:ns:domain-1.0" - ObjHost = "urn:ietf:params:xml:ns:host-1.0" - ObjContact = "urn:ietf:params:xml:ns:contact-1.0" - ObjFinance = "http://www.unitedtld.com/epp/finance-1.0" - ExtSecDNS = "urn:ietf:params:xml:ns:secDNS-1.1" - ExtRGP = "urn:ietf:params:xml:ns:rgp-1.0" - ExtLaunch = "urn:ietf:params:xml:ns:launch-1.0" - ExtIDN = "urn:ietf:params:xml:ns:idn-1.0" - ExtCharge = "http://www.unitedtld.com/epp/charge-1.0" - ExtFee05 = "urn:ietf:params:xml:ns:fee-0.5" - ExtFee06 = "urn:ietf:params:xml:ns:fee-0.6" - ExtFee07 = "urn:ietf:params:xml:ns:fee-0.7" - ExtFee08 = "urn:ietf:params:xml:ns:fee-0.8" - ExtFee09 = "urn:ietf:params:xml:ns:fee-0.9" - ExtFee11 = "urn:ietf:params:xml:ns:fee-0.11" - ExtFee21 = "urn:ietf:params:xml:ns:fee-0.21" - ExtFee10 = "urn:ietf:params:xml:ns:epp:fee-1.0" - ExtPrice = "urn:ar:params:xml:ns:price-1.1" - ExtNamestore = "http://www.verisign-grs.com/epp/namestoreExt-1.1" - ExtNeulevel = "urn:ietf:params:xml:ns:neulevel" - ExtNeulevel10 = "urn:ietf:params:xml:ns:neulevel-1.0" -) - -// ExtURNNames maps short extension names to their full URN. -var ExtURNNames = map[string]string{ - "secDNS-1.1": ExtSecDNS, - "rgp-1.0": ExtRGP, - "launch-1.0": ExtLaunch, - "idn-1.0": ExtIDN, - "charge-1.0": ExtCharge, - "fee-0.5": ExtFee05, - "fee-0.6": ExtFee06, - "fee-0.7": ExtFee07, - "fee-0.8": ExtFee08, - "fee-0.9": ExtFee09, - "fee-0.11": ExtFee11, - "fee-0.21": ExtFee21, - "fee-1.0": ExtFee10, - "price-1.1": ExtPrice, - "namestoreExt-1.1": ExtNamestore, - "neulevel": ExtNeulevel, - "neulevel-1.0": ExtNeulevel10, -} - -// TODO: check if res.Greeting is not empty. -func (c *Conn) readGreeting() (Greeting, error) { - res, err := c.readResponse() - if err != nil { - return Greeting{}, err - } - return res.Greeting, nil -} - -func init() { - path := "epp>greeting" - scanResponse.MustHandleCharData(path+">svID", func(c *xx.Context) error { - res := c.Value.(*Response) - res.Greeting.ServerName = string(c.CharData) - return nil - }) - scanResponse.MustHandleCharData(path+">svcMenu>version", func(c *xx.Context) error { - res := c.Value.(*Response) - res.Greeting.Versions = append(res.Greeting.Versions, string(c.CharData)) - return nil - }) - scanResponse.MustHandleCharData(path+">svcMenu>lang", func(c *xx.Context) error { - res := c.Value.(*Response) - res.Greeting.Languages = append(res.Greeting.Languages, string(c.CharData)) - return nil - }) - scanResponse.MustHandleCharData(path+">svcMenu>objURI", func(c *xx.Context) error { - res := c.Value.(*Response) - res.Greeting.Objects = append(res.Greeting.Objects, string(c.CharData)) - return nil - }) - scanResponse.MustHandleCharData(path+">svcMenu>svcExtension>extURI", func(c *xx.Context) error { - res := c.Value.(*Response) - res.Greeting.Extensions = append(res.Greeting.Extensions, string(c.CharData)) - return nil - }) -} diff --git a/greeting_test.go b/greeting_test.go deleted file mode 100644 index dbbcfc4..0000000 --- a/greeting_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package epp - -import ( - "bytes" - "encoding/xml" - "net" - "testing" - - "github.com/nbio/st" -) - -func TestHello(t *testing.T) { - ls, err := newLocalServer() - st.Assert(t, err, nil) - defer ls.teardown() - ls.buildup(func(ls *localServer, ln net.Listener) { - conn, err := ls.Accept() - st.Assert(t, err, nil) - // Respond with greeting - err = writeDataUnit(conn, []byte(testXMLGreeting)) - st.Assert(t, err, nil) - // Respond with greeting for - err = writeDataUnit(conn, []byte(testXMLGreeting)) - st.Assert(t, err, nil) - }) - nc, err := net.Dial(ls.Listener.Addr().Network(), ls.Listener.Addr().String()) - st.Assert(t, err, nil) - - c, err := NewConn(nc) - st.Assert(t, err, nil) - err = c.Hello() - st.Expect(t, err, nil) - st.Expect(t, c.Greeting.ServerName, "Example EPP server epp.example.com") -} - -func TestGreetingSupportsObject(t *testing.T) { - g := Greeting{} - st.Expect(t, g.SupportsObject(ObjDomain), false) - st.Expect(t, g.SupportsObject(ObjHost), false) - g.Objects = testObjects - st.Expect(t, g.SupportsObject(ObjDomain), true) - st.Expect(t, g.SupportsObject(ObjHost), true) -} - -func TestGreetingSupportsExtension(t *testing.T) { - g := Greeting{} - st.Expect(t, g.SupportsExtension(ExtCharge), false) - st.Expect(t, g.SupportsExtension(ExtIDN), false) - g.Extensions = testExtensions - st.Expect(t, g.SupportsExtension(ExtCharge), true) - st.Expect(t, g.SupportsExtension(ExtIDN), true) -} - -func TestScanGreeting(t *testing.T) { - d := decoder(testXMLGreeting) - var res Response - err := IgnoreEOF(scanResponse.Scan(d, &res)) - st.Expect(t, err, nil) - st.Expect(t, res.Greeting.ServerName, "Example EPP server epp.example.com") - st.Expect(t, res.Greeting.Objects[0], "urn:ietf:params:xml:ns:obj1") - st.Expect(t, res.Greeting.Objects[1], "urn:ietf:params:xml:ns:obj2") - st.Expect(t, res.Greeting.Objects[2], "urn:ietf:params:xml:ns:obj3") - st.Expect(t, res.Greeting.Extensions[0], "http://custom/obj1ext-1.0") -} - -func BenchmarkScanGreeting(b *testing.B) { - b.StopTimer() - var buf bytes.Buffer - d := xml.NewDecoder(&buf) - saved := *d - b.StartTimer() - for i := 0; i < b.N; i++ { - b.StopTimer() - buf.Reset() - buf.WriteString(testXMLGreeting) - deleteBufferRange(&buf, []byte(``), []byte(``)) - *d = saved - b.StartTimer() - var res Response - scanResponse.Scan(d, &res) - } -} - -var testXMLGreeting = ` - - - Example EPP server epp.example.com - 2000-06-08T22:00:00.0Z - - 1.0 - en - fr - urn:ietf:params:xml:ns:obj1 - urn:ietf:params:xml:ns:obj2 - urn:ietf:params:xml:ns:obj3 - - http://custom/obj1ext-1.0 - - - - - - - - - - - -` diff --git a/id.go b/id.go new file mode 100644 index 0000000..a0e3431 --- /dev/null +++ b/id.go @@ -0,0 +1,35 @@ +package epp + +import ( + "crypto/rand" + "encoding/hex" + "strconv" + "sync/atomic" +) + +type ID interface { + ID() string +} + +type seqSource struct { + prefix string + n uint64 +} + +func newSeqSource(prefix string) (*seqSource, error) { + if prefix == "" { + var pfx [16]byte + _, err := rand.Read(pfx[:]) + if err != nil { + return nil, err + } + prefix = hex.EncodeToString(pfx[:]) + } + return &seqSource{ + prefix: prefix, + }, nil +} + +func (s *seqSource) ID() string { + return s.prefix + strconv.FormatUint(atomic.AddUint64(&s.n, 1), 10) +} diff --git a/info.go b/info.go deleted file mode 100644 index d732cfd..0000000 --- a/info.go +++ /dev/null @@ -1,197 +0,0 @@ -package epp - -import ( - "bytes" - "encoding/xml" - "time" - - "github.com/nbio/xx" -) - -// DomainInfo retrieves info for a domain. -// https://tools.ietf.org/html/rfc5731#section-3.1.2 -func (c *Conn) DomainInfo(domain string, extData map[string]string) (*DomainInfoResponse, error) { - x, err := encodeDomainInfo(&c.Greeting, domain, extData) - if err != nil { - return nil, err - } - err = c.writeRequest(x) - if err != nil { - return nil, err - } - res, err := c.readResponse() - if err != nil { - return nil, err - } - return &res.DomainInfoResponse, nil -} - -func encodeDomainInfo(greeting *Greeting, domain string, extData map[string]string) ([]byte, error) { - buf := bytes.NewBufferString(xmlCommandPrefix) - buf.WriteString(``) - xml.EscapeText(buf, []byte(domain)) - buf.WriteString(``) - - supportsNamestore := extData["namestoreExt:subProduct"] != "" && greeting.SupportsExtension(ExtNamestore) - hasExtension := supportsNamestore - - if hasExtension { - buf.WriteString(``) - // https://www.verisign.com/assets/epp-sdk/verisign_epp-extension_namestoreext_v01.html - if supportsNamestore { - buf.WriteString(``) - buf.WriteString(``) - buf.WriteString(extData["namestoreExt:subProduct"]) - buf.WriteString(``) - buf.WriteString(``) - } - buf.WriteString(``) - } - - buf.WriteString(xmlCommandSuffix) - - return buf.Bytes(), nil -} - -// DomainInfoResponse represents an EPP response for a domain info request. -// https://tools.ietf.org/html/rfc5731#section-3.1.2 -type DomainInfoResponse struct { - Domain string // - ID string // - ClID string // - UpID string // - CrDate time.Time // - ExDate time.Time // - UpDate time.Time // - TrDate time.Time // - Status []string // -} - -func init() { - // Default EPP check data - path := "epp > response > resData > " + ObjDomain + " infData" - scanResponse.MustHandleCharData(path+">name", func(c *xx.Context) error { - dir := &c.Value.(*Response).DomainInfoResponse - dir.Domain = string(c.CharData) - return nil - }) - scanResponse.MustHandleCharData(path+">roid", func(c *xx.Context) error { - dir := &c.Value.(*Response).DomainInfoResponse - dir.ID = string(c.CharData) - return nil - }) - scanResponse.MustHandleCharData(path+">clID", func(c *xx.Context) error { - dir := &c.Value.(*Response).DomainInfoResponse - dir.ClID = string(c.CharData) - return nil - }) - scanResponse.MustHandleCharData(path+">upID", func(c *xx.Context) error { - dir := &c.Value.(*Response).DomainInfoResponse - dir.UpID = string(c.CharData) - return nil - }) - scanResponse.MustHandleCharData(path+">crDate", func(c *xx.Context) error { - dir := &c.Value.(*Response).DomainInfoResponse - var err error - dir.CrDate, err = time.Parse(time.RFC3339, string(c.CharData)) - return err - }) - scanResponse.MustHandleCharData(path+">exDate", func(c *xx.Context) error { - dir := &c.Value.(*Response).DomainInfoResponse - var err error - dir.ExDate, err = time.Parse(time.RFC3339, string(c.CharData)) - return err - }) - scanResponse.MustHandleCharData(path+">upDate", func(c *xx.Context) error { - dir := &c.Value.(*Response).DomainInfoResponse - var err error - dir.UpDate, err = time.Parse(time.RFC3339, string(c.CharData)) - return err - }) - scanResponse.MustHandleCharData(path+">trDate", func(c *xx.Context) error { - dir := &c.Value.(*Response).DomainInfoResponse - var err error - dir.TrDate, err = time.Parse(time.RFC3339, string(c.CharData)) - return err - }) - scanResponse.MustHandleStartElement(path+">status", func(c *xx.Context) error { - dir := &c.Value.(*Response).DomainInfoResponse - dir.Status = append(dir.Status, c.Attr("", "s")) - return nil - }) -} - -//lint:ignore U1000 keeping around for reference -func encodeVerisignDomainInfo(buf *bytes.Buffer, domain string) error { - buf.Reset() - buf.WriteString(xmlCommandPrefix) - buf.WriteString(``) - xml.EscapeText(buf, []byte(domain)) - buf.WriteString(``) - buf.WriteString(``) - buf.WriteString(``) - buf.WriteString(``) - buf.WriteString(`com`) - buf.WriteString(``) - buf.WriteString(``) - buf.WriteString(``) - buf.WriteString(xmlCommandSuffix) - return nil -} - -//lint:ignore U1000 keeping around for reference -func encodeVerisignContactInfo(buf *bytes.Buffer, contact string) error { - buf.Reset() - buf.WriteString(xmlCommandPrefix) - buf.WriteString(``) - xml.EscapeText(buf, []byte(contact)) - buf.WriteString(``) - buf.WriteString(``) - buf.WriteString(``) - buf.WriteString(``) - buf.WriteString(`com`) - buf.WriteString(``) - buf.WriteString(``) - buf.WriteString(``) - buf.WriteString(xmlCommandSuffix) - return nil -} - -/* - - - - - - example.com - - - - - TLD - - - ABC-12345 - - - - - - - - - - sh8013 - - 2fooBAR - - - - ABC-12345 - - -*/ diff --git a/internal/schema/common/ns.go b/internal/schema/common/ns.go new file mode 100644 index 0000000..b489c34 --- /dev/null +++ b/internal/schema/common/ns.go @@ -0,0 +1,5 @@ +package common + +// NS defines the IETF URN for the EPP common namespace. +// See https://www.iana.org/assignments/xml-registry/ns/eppcom-1.0.txt. +const NS = "urn:ietf:params:xml:ns:eppcom-1.0" diff --git a/internal/schema/contact/ns.go b/internal/schema/contact/ns.go new file mode 100644 index 0000000..b7ad758 --- /dev/null +++ b/internal/schema/contact/ns.go @@ -0,0 +1,5 @@ +package contact + +// Host defines the IETF URN for the EPP contact namespace. +// See https://www.iana.org/assignments/xml-registry/ns/contact-1.0.txt. +const NS = "urn:ietf:params:xml:ns:contact-1.0" diff --git a/internal/schema/domain/check.go b/internal/schema/domain/check.go new file mode 100644 index 0000000..d1d40d4 --- /dev/null +++ b/internal/schema/domain/check.go @@ -0,0 +1,10 @@ +package domain + +// Check represents an EPP command. +// See https://www.rfc-editor.org/rfc/rfc5730.html. +type Check struct { + XMLName struct{} `xml:"urn:ietf:params:xml:ns:domain-1.0 domain:check"` + Names []string `xml:"domain:name,omitempty"` +} + +func (Check) EPPCheck() {} diff --git a/internal/schema/domain/ns.go b/internal/schema/domain/ns.go new file mode 100644 index 0000000..ea6b7c7 --- /dev/null +++ b/internal/schema/domain/ns.go @@ -0,0 +1,6 @@ +package domain + +// NS defines the IETF URN for the EPP domain namespace. +// See https://www.iana.org/assignments/xml-registry/ns/domain-1.0.txt +// and https://datatracker.ietf.org/doc/html/rfc5731. +const NS = "urn:ietf:params:xml:ns:domain-1.0" diff --git a/internal/schema/epp/check.go b/internal/schema/epp/check.go new file mode 100644 index 0000000..fc83015 --- /dev/null +++ b/internal/schema/epp/check.go @@ -0,0 +1,40 @@ +package epp + +import ( + "github.com/domainr/epp/internal/schema/domain" + "github.com/nbio/xml" +) + +// Check represents an EPP command as defined in RFC 5730. +// See https://www.rfc-editor.org/rfc/rfc5730.html#section-2.9.2.1. +type Check struct { + XMLName struct{} `xml:"urn:ietf:params:xml:ns:epp-1.0 check"` + Check check +} + +func (Check) eppCommand() {} + +// UnmarshalXML implements the xml.Unmarshaler interface. +// It maps known EPP check commands to their corresponding Go type. +func (c *Check) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + type T Check + var v struct { + DomainCheck *domain.Check + // TODO: HostCheck, etc. + *T + } + v.T = (*T)(c) + err := d.DecodeElement(&v, &start) + if err != nil { + return err + } + switch { + case v.DomainCheck != nil: + c.Check = v.DomainCheck + } + return nil +} + +type check interface { + EPPCheck() +} diff --git a/internal/schema/epp/command.go b/internal/schema/epp/command.go new file mode 100644 index 0000000..333d439 --- /dev/null +++ b/internal/schema/epp/command.go @@ -0,0 +1,68 @@ +package epp + +import ( + "github.com/nbio/xml" +) + +// Command represents an EPP client as defined in RFC 5730. +// See https://www.rfc-editor.org/rfc/rfc5730.html#section-2.5. +type Command struct { + XMLName struct{} `xml:"urn:ietf:params:xml:ns:epp-1.0 command"` + Command command + ClientTransactionID string `xml:"clTRID,omitempty"` +} + +func (Command) eppBody() {} + +// UnmarshalXML implements the xml.Unmarshaler interface. +// It maps known EPP commands to their corresponding Go type. +func (c *Command) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + type T Command + var v struct { + Check *Check `xml:"check"` + Create *Create `xml:"create"` + Delete *Delete `xml:"delete"` + Info *Info `xml:"info"` + Login *Login `xml:"login"` + Logout *Logout `xml:"logout"` + Poll *Poll `xml:"poll"` + Renew *Renew `xml:"renew"` + Transfer *Transfer `xml:"transfer"` + Update *Update `xml:"update"` + *T + } + v.T = (*T)(c) + err := d.DecodeElement(&v, &start) + if err != nil { + return err + } + switch { + case v.Check != nil: + c.Command = v.Check + case v.Create != nil: + c.Command = v.Create + case v.Delete != nil: + c.Command = v.Delete + case v.Info != nil: + c.Command = v.Info + case v.Login != nil: + c.Command = v.Login + case v.Logout != nil: + c.Command = v.Logout + case v.Poll != nil: + c.Command = v.Poll + case v.Renew != nil: + c.Command = v.Renew + case v.Transfer != nil: + c.Command = v.Transfer + case v.Update != nil: + c.Command = v.Update + } + return nil +} + +// command is a child element of EPP . +// Concrete command types implement this interface. +type command interface { + eppCommand() +} diff --git a/internal/schema/epp/command_test.go b/internal/schema/epp/command_test.go new file mode 100644 index 0000000..0016ddc --- /dev/null +++ b/internal/schema/epp/command_test.go @@ -0,0 +1,56 @@ +package epp_test + +import ( + "testing" + + "github.com/domainr/epp/internal/schema/domain" + "github.com/domainr/epp/internal/schema/epp" + "github.com/domainr/epp/internal/schema/test" +) + +func TestCommandRoundTrip(t *testing.T) { + tests := []struct { + name string + v interface{} + want string + wantErr bool + }{ + { + `empty `, + &epp.EPP{Body: &epp.Command{}}, + ``, + false, + }, + { + `empty command`, + &epp.EPP{ + Body: &epp.Command{ + Command: &epp.Check{ + Check: &domain.Check{}, + }, + }, + }, + ``, + false, + }, + { + `single command`, + &epp.EPP{ + Body: &epp.Command{ + Command: &epp.Check{ + Check: &domain.Check{ + Names: []string{"example.com"}, + }, + }, + }, + }, + `example.com`, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + test.RoundTrip(t, tt.v, tt.want, tt.wantErr) + }) + } +} diff --git a/internal/schema/epp/create.go b/internal/schema/epp/create.go new file mode 100644 index 0000000..aa426d4 --- /dev/null +++ b/internal/schema/epp/create.go @@ -0,0 +1,10 @@ +package epp + +// Create represents an EPP command as defined in RFC 5730. +// See https://www.rfc-editor.org/rfc/rfc5730.html#section-2.9.3.1. +type Create struct { + // TODO: DomainCreate *domain.Create + // TODO: HostCreate *host.Create +} + +func (Create) eppCommand() {} diff --git a/internal/schema/epp/delete.go b/internal/schema/epp/delete.go new file mode 100644 index 0000000..5ebc2fc --- /dev/null +++ b/internal/schema/epp/delete.go @@ -0,0 +1,10 @@ +package epp + +// Delete represents an EPP command as defined in RFC 5730. +// See https://www.rfc-editor.org/rfc/rfc5730.html#section-2.9.3.1. +type Delete struct { + // TODO: DomainDelete *domain.Delete + // TODO: HostDelete *host.Delete +} + +func (Delete) eppCommand() {} diff --git a/internal/schema/epp/epp.go b/internal/schema/epp/epp.go new file mode 100644 index 0000000..7e33835 --- /dev/null +++ b/internal/schema/epp/epp.go @@ -0,0 +1,44 @@ +package epp + +import ( + "github.com/nbio/xml" +) + +// EPP represents an element as defined in RFC 5730. +// See https://www.rfc-editor.org/rfc/rfc5730.html. +type EPP struct { + XMLName struct{} `xml:"urn:ietf:params:xml:ns:epp-1.0 epp"` + + // Body is any valid EPP child element. + Body Body +} + +func (e *EPP) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { + var v struct { + Hello *Hello `xml:"hello"` + Greeting *Greeting `xml:"greeting"` + Command *Command `xml:"command"` + Response *Response `xml:"response"` + } + err := d.DecodeElement(&v, &start) + if err != nil { + return err + } + switch { + case v.Hello != nil: + e.Body = v.Hello + case v.Greeting != nil: + e.Body = v.Greeting + case v.Command != nil: + e.Body = v.Command + case v.Response != nil: + e.Body = v.Response + } + return nil +} + +// Body represents a valid EPP body element: +// , , , and . +type Body interface { + eppBody() +} diff --git a/internal/schema/epp/epp_test.go b/internal/schema/epp/epp_test.go new file mode 100644 index 0000000..37f4287 --- /dev/null +++ b/internal/schema/epp/epp_test.go @@ -0,0 +1,53 @@ +package epp_test + +import ( + "testing" + + "github.com/domainr/epp/internal/schema/epp" + "github.com/domainr/epp/internal/schema/test" +) + +func TestEPPRoundTrip(t *testing.T) { + tests := []struct { + name string + v interface{} + want string + wantErr bool + }{ + { + `nil`, + nil, + ``, + false, + }, + { + `empty element`, + &epp.EPP{}, + ``, + false, + }, + { + ` with element`, + &epp.EPP{Body: &epp.Hello{}}, + ``, + false, + }, + { + `empty `, + &epp.EPP{Body: &epp.Greeting{}}, + ``, + false, + }, + { + `empty `, + &epp.EPP{Body: &epp.Command{}}, + ``, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + test.RoundTrip(t, tt.v, tt.want, tt.wantErr) + }) + } +} diff --git a/internal/schema/epp/greeting.go b/internal/schema/epp/greeting.go new file mode 100644 index 0000000..1ff4318 --- /dev/null +++ b/internal/schema/epp/greeting.go @@ -0,0 +1,102 @@ +package epp + +import ( + "github.com/domainr/epp/internal/schema/std" + "github.com/nbio/xml" +) + +// Greeting represents an EPP server message as defined in RFC 5730. +type Greeting struct { + XMLName struct{} `xml:"urn:ietf:params:xml:ns:epp-1.0 greeting"` + ServerName string `xml:"svID,omitempty"` + ServerDate *std.Time `xml:"svDate"` + ServiceMenu *ServiceMenu `xml:"svcMenu"` + DCP *DCP `xml:"dcp"` +} + +func (Greeting) eppBody() {} + +// ServiceMenu represents an EPP element as defined in RFC 5730. +type ServiceMenu struct { + Versions []string `xml:"version"` + Languages []string `xml:"lang"` + Objects []string `xml:"objURI"` + ServiceExtension *ServiceExtension `xml:"svcExtension"` +} + +// DCP represents a server data collection policy as defined in RFC 5730. +type DCP struct { + Access Access `xml:"access"` + Statements []Statement `xml:"statement"` + Expiry *Expiry `xml:"expiry"` +} + +// Access represents an EPP server’s scope of data access as defined in RFC 5730. +type Access struct { + Null std.Bool `xml:"null"` + All std.Bool `xml:"all"` + None std.Bool `xml:"none"` + Other std.Bool `xml:"other"` + Personal std.Bool `xml:"personal"` + PersonalAndOther std.Bool `xml:"personalAndOther"` +} + +var ( + AccessNull = Access{Null: std.True} + AccessAll = Access{All: std.True} + AccessNone = Access{None: std.True} + AccessOther = Access{Other: std.True} + AccessPersonal = Access{Personal: std.True} + AccessPersonalAndOther = Access{PersonalAndOther: std.True} +) + +// Statement describes an EPP server’s data collection purpose, receipient(s), and retention policy. +type Statement struct { + Purpose Purpose `xml:"purpose"` + Recipient Recipient `xml:"recipient"` +} + +// Purpose represents an EPP server’s purpose for data collection. +type Purpose struct { + Admin std.Bool `xml:"admin"` + Contact std.Bool `xml:"contact"` + Provisioning std.Bool `xml:"provisioning"` + Other std.Bool `xml:"other"` +} + +var ( + PurposeAdmin = Purpose{Admin: std.True} + PurposeContact = Purpose{Contact: std.True} + PurposeProvisioning = Purpose{Provisioning: std.True} + PurposeOther = Purpose{Other: std.True} +) + +// Recipient represents an EPP server’s purpose for data collection. +type Recipient struct { + Other std.Bool `xml:"other"` + Ours *Ours `xml:"ours"` + Public std.Bool `xml:"public"` + Same std.Bool `xml:"same"` + Unrelated std.Bool `xml:"unrelated"` +} + +// Ours represents an EPP server’s description of an recipient. +type Ours struct { + Recipient string `xml:"recDesc"` +} + +// MarshalXML impements the xml.Marshaler interface. +// Writes a single self-closing if v.Recipient is not set. +func (v *Ours) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + if v.Recipient == "" { + return e.EncodeToken(xml.SelfClosingElement(start)) + } + type T Ours + return e.EncodeElement((*T)(v), start) +} + +// Expiry defines an EPP server’s data retention duration. +type Expiry struct { + Absolute *std.Time `xml:"absolute"` + Relative *std.Duration `xml:"relative"` +} diff --git a/internal/schema/epp/greeting_test.go b/internal/schema/epp/greeting_test.go new file mode 100644 index 0000000..b9c6690 --- /dev/null +++ b/internal/schema/epp/greeting_test.go @@ -0,0 +1,132 @@ +package epp_test + +import ( + "testing" + + "github.com/domainr/epp/internal/schema/contact" + "github.com/domainr/epp/internal/schema/domain" + "github.com/domainr/epp/internal/schema/epp" + "github.com/domainr/epp/internal/schema/host" + "github.com/domainr/epp/internal/schema/std" + "github.com/domainr/epp/internal/schema/test" +) + +func TestGreetingRoundTrip(t *testing.T) { + tests := []struct { + name string + v interface{} + want string + wantErr bool + }{ + { + `empty `, + &epp.EPP{Body: &epp.Greeting{}}, + ``, + false, + }, + { + `simple `, + &epp.EPP{ + Body: &epp.Greeting{ + ServerName: "Test EPP Server", + ServerDate: std.ParseTime("2000-01-01T00:00:00Z").Pointer(), + }, + }, + `Test EPP Server2000-01-01T00:00:00Z`, + false, + }, + { + `complex `, + &epp.EPP{ + Body: &epp.Greeting{ + ServerName: "Test EPP Server", + ServerDate: std.ParseTime("2000-01-01T00:00:00Z").Pointer(), + ServiceMenu: &epp.ServiceMenu{ + Versions: []string{"1.0"}, + Languages: []string{"en", "fr"}, + Objects: []string{contact.NS, domain.NS, host.NS}, + }, + DCP: &epp.DCP{}, + }, + }, + `Test EPP Server2000-01-01T00:00:00Z1.0enfrurn:ietf:params:xml:ns:contact-1.0urn:ietf:params:xml:ns:domain-1.0urn:ietf:params:xml:ns:host-1.0`, + false, + }, + { + `complex with complex `, + &epp.EPP{ + Body: &epp.Greeting{ + ServerName: "Test EPP Server", + ServerDate: std.ParseTime("2000-01-01T00:00:00Z").Pointer(), + ServiceMenu: &epp.ServiceMenu{ + Versions: []string{"1.0"}, + Languages: []string{"en", "fr"}, + Objects: []string{contact.NS, domain.NS, host.NS}, + }, + DCP: &epp.DCP{ + Access: epp.AccessPersonalAndOther, + Statements: []epp.Statement{ + { + Purpose: epp.PurposeAdmin, + Recipient: epp.Recipient{Ours: &epp.Ours{Recipient: "Domainr"}, Public: std.True}, + }, + { + Purpose: epp.Purpose{Contact: std.True, Other: std.True}, + Recipient: epp.Recipient{Other: std.True, Ours: &epp.Ours{}, Public: std.True}, + }, + }, + Expiry: &epp.Expiry{ + Relative: std.ParseDuration("P1Y").Pointer(), + }, + }, + }, + }, + `Test EPP Server2000-01-01T00:00:00Z1.0enfrurn:ietf:params:xml:ns:contact-1.0urn:ietf:params:xml:ns:domain-1.0urn:ietf:params:xml:ns:host-1.0DomainrP365DT5H49M12S`, + false, + }, + { + ` with with absolute expiry`, + &epp.EPP{ + Body: &epp.Greeting{ + DCP: &epp.DCP{ + Expiry: &epp.Expiry{ + Absolute: std.ParseTime("2000-01-01T00:00:00Z").Pointer(), + }, + }, + }, + }, + `2000-01-01T00:00:00Z`, + false, + }, + { + `complex with extensions`, + &epp.EPP{ + Body: &epp.Greeting{ + ServerName: "Test EPP Server", + ServerDate: std.ParseTime("2000-01-01T00:00:00Z").Pointer(), + ServiceMenu: &epp.ServiceMenu{ + Versions: []string{"1.0"}, + Languages: []string{"en", "fr"}, + Objects: []string{contact.NS, domain.NS, host.NS}, + ServiceExtension: &epp.ServiceExtension{ + Extensions: []string{ + "urn:ietf:params:xml:ns:fee-0.8", + "urn:ietf:params:xml:ns:epp:fee-1.0", + }, + }, + }, + DCP: &epp.DCP{ + Access: epp.AccessNull, + }, + }, + }, + `Test EPP Server2000-01-01T00:00:00Z1.0enfrurn:ietf:params:xml:ns:contact-1.0urn:ietf:params:xml:ns:domain-1.0urn:ietf:params:xml:ns:host-1.0urn:ietf:params:xml:ns:fee-0.8urn:ietf:params:xml:ns:epp:fee-1.0`, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + test.RoundTrip(t, tt.v, tt.want, tt.wantErr) + }) + } +} diff --git a/internal/schema/epp/hello.go b/internal/schema/epp/hello.go new file mode 100644 index 0000000..277c2dd --- /dev/null +++ b/internal/schema/epp/hello.go @@ -0,0 +1,7 @@ +package epp + +type Hello struct { + XMLName struct{} `xml:"urn:ietf:params:xml:ns:epp-1.0 hello,selfclosing"` +} + +func (Hello) eppBody() {} diff --git a/internal/schema/epp/info.go b/internal/schema/epp/info.go new file mode 100644 index 0000000..cc5de48 --- /dev/null +++ b/internal/schema/epp/info.go @@ -0,0 +1,9 @@ +package epp + +// Info represents an EPP command as defined in RFC 5730. +// See https://www.rfc-editor.org/rfc/rfc5730.html#section-2.9.2.2. +type Info struct { + // TODO: DomainInfo *domain.Info +} + +func (Info) eppCommand() {} diff --git a/internal/schema/epp/login.go b/internal/schema/epp/login.go new file mode 100644 index 0000000..a742c9c --- /dev/null +++ b/internal/schema/epp/login.go @@ -0,0 +1,53 @@ +package epp + +// Login represents an EPP command as defined in RFC 5730. +// See https://www.rfc-editor.org/rfc/rfc5730.html#section-2.9.1.1. +type Login struct { + XMLName struct{} `xml:"urn:ietf:params:xml:ns:epp-1.0 login"` + ClientID string `xml:"clID"` + Password string `xml:"pw"` + NewPassword *string `xml:"newPW"` + Options Options `xml:"options"` + Services Services `xml:"svcs"` + command +} + +func (Login) eppCommand() {} + +// Options represent EPP login options as defined in RFC 5730. +type Options struct { + Version string `xml:"version"` + Lang string `xml:"lang,omitempty"` +} + +// Services represent EPP login services as defined in RFC 5730. +type Services struct { + Objects []string `xml:"objURI,omitempty"` + ServiceExtension *ServiceExtension `xml:"svcExtension"` +} + +/* + + + + + ClientX + foo-BAR2 + bar-FOO2 + + 1.0 + en + + + urn:ietf:params:xml:ns:obj1 + urn:ietf:params:xml:ns:obj2 + urn:ietf:params:xml:ns:obj3 + + http://custom/obj1ext-1.0 + + + + ABC-12345 + + +*/ diff --git a/internal/schema/epp/login_test.go b/internal/schema/epp/login_test.go new file mode 100644 index 0000000..7760a24 --- /dev/null +++ b/internal/schema/epp/login_test.go @@ -0,0 +1,122 @@ +package epp_test + +import ( + "testing" + + "github.com/domainr/epp/internal/schema/epp" + "github.com/domainr/epp/internal/schema/std" + "github.com/domainr/epp/internal/schema/test" + "github.com/domainr/epp/ns" +) + +func TestLoginRoundTrip(t *testing.T) { + tests := []struct { + name string + v interface{} + want string + wantErr bool + }{ + { + `empty `, + &epp.EPP{Body: &epp.Command{Command: &epp.Login{}}}, + ``, + false, + }, + { + `simple `, + &epp.EPP{ + Body: &epp.Command{ + Command: &epp.Login{ + ClientID: "user", + Password: "password", + }, + }, + }, + `userpassword`, + false, + }, + { + `specify version 1.0`, + &epp.EPP{ + Body: &epp.Command{ + Command: &epp.Login{ + ClientID: "user", + Password: "password", + Options: epp.Options{ + Version: epp.Version, + }, + }, + }, + }, + `userpassword1.0`, + false, + }, + { + `specify lang=en`, + &epp.EPP{ + Body: &epp.Command{ + Command: &epp.Login{ + ClientID: "user", + Password: "password", + Options: epp.Options{ + Version: epp.Version, + Lang: "en", + }, + }, + }, + }, + `userpassword1.0en`, + false, + }, + { + `change password`, + &epp.EPP{ + Body: &epp.Command{ + Command: &epp.Login{ + ClientID: "user", + Password: "password", + NewPassword: std.StringPointer("newpassword"), + Options: epp.Options{ + Version: epp.Version, + Lang: "en", + }, + }, + }, + }, + `userpasswordnewpassword1.0en`, + false, + }, + { + `complex `, + &epp.EPP{ + Body: &epp.Command{ + Command: &epp.Login{ + ClientID: "user", + NewPassword: std.StringPointer("newpassword"), + Options: epp.Options{ + Version: epp.Version, + Lang: "en", + }, + Services: epp.Services{ + Objects: []string{ns.Domain, ns.Contact, ns.Host}, + ServiceExtension: &epp.ServiceExtension{ + Extensions: []string{ + "urn:ietf:params:xml:ns:epp:fee-0.8", + "urn:ietf:params:xml:ns:epp:fee-1.0", + "urn:ietf:params:xml:ns:idn-1.0", + }, + }, + }, + }, + }, + }, + `usernewpassword1.0enurn:ietf:params:xml:ns:domain-1.0urn:ietf:params:xml:ns:contact-1.0urn:ietf:params:xml:ns:host-1.0urn:ietf:params:xml:ns:epp:fee-0.8urn:ietf:params:xml:ns:epp:fee-1.0urn:ietf:params:xml:ns:idn-1.0`, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + test.RoundTrip(t, tt.v, tt.want, tt.wantErr) + }) + } +} diff --git a/internal/schema/epp/logout.go b/internal/schema/epp/logout.go new file mode 100644 index 0000000..918b932 --- /dev/null +++ b/internal/schema/epp/logout.go @@ -0,0 +1,7 @@ +package epp + +type Logout struct { + XMLName struct{} `xml:"urn:ietf:params:xml:ns:epp-1.0 logout,selfclosing"` +} + +func (Logout) eppCommand() {} diff --git a/internal/schema/epp/message.go b/internal/schema/epp/message.go new file mode 100644 index 0000000..23cdc95 --- /dev/null +++ b/internal/schema/epp/message.go @@ -0,0 +1,8 @@ +package epp + +// Message represents an human-readable message + optional language identifier. +// Used in epp>response>result>msg and epp>response>result>extValue>reason. +type Message struct { + Lang string `xml:"lang,attr,omitempty"` + Value string `xml:",chardata"` +} diff --git a/internal/schema/epp/message_queue.go b/internal/schema/epp/message_queue.go new file mode 100644 index 0000000..ba89f22 --- /dev/null +++ b/internal/schema/epp/message_queue.go @@ -0,0 +1,46 @@ +package epp + +import ( + "github.com/domainr/epp/internal/schema/std" + "github.com/nbio/xml" +) + +// MessageQueue represents an EPP server as defined in RFC 5730. +type MessageQueue struct { + // The count attribute describes the number of messages that exist in + // the queue. + Count uint64 `xml:"count,attr"` + + // The id attribute is used to uniquely identify the message at the head + // of the queue. + ID string `xml:"id,attr"` + + // The element contains the following OPTIONAL child elements + // that MUST be returned in response to a request command and + // MUST NOT be returned in response to any other command, including a + // acknowledgement. + + // The element that contains the date and time that the message + // was enqueued. + Date *std.Time `xml:"qDate"` + + // The element contains a human-readable message. + // TODO: This element MAY contain XML content for formatting purposes, + // but the XML content is not specified by the protocol and will thus + // not be processed for validity. + Message *Message `xml:"msg"` +} + +// MarshalXML impements the xml.Marshaler interface. +// Writes a single self-closing tag if q.Date and q.Message are not set. +func (q *MessageQueue) MarshalXML(e *xml.Encoder, start xml.StartElement) error { + type T MessageQueue + type W struct { + XMLName struct{} `xml:",selfclosing"` + *T + } + if q.Date == nil && q.Message == nil { + return e.EncodeElement(&W{T: (*T)(q)}, start) + } + return e.EncodeElement((*T)(q), start) +} diff --git a/internal/schema/epp/ns.go b/internal/schema/epp/ns.go new file mode 100644 index 0000000..ed46b5b --- /dev/null +++ b/internal/schema/epp/ns.go @@ -0,0 +1,5 @@ +package epp + +// EPP defines the IETF URN for the EPP namespace. +// See https://www.iana.org/assignments/xml-registry/ns/epp-1.0.txt. +const NS = "urn:ietf:params:xml:ns:epp-1.0" diff --git a/internal/schema/epp/poll.go b/internal/schema/epp/poll.go new file mode 100644 index 0000000..5e85770 --- /dev/null +++ b/internal/schema/epp/poll.go @@ -0,0 +1,8 @@ +package epp + +// Poll represents an EPP command as defined in RFC 5730. +// See https://www.rfc-editor.org/rfc/rfc5730.html#section-2.9.2.3. +type Poll struct { +} + +func (Poll) eppCommand() {} diff --git a/internal/schema/epp/renew.go b/internal/schema/epp/renew.go new file mode 100644 index 0000000..a9e6a80 --- /dev/null +++ b/internal/schema/epp/renew.go @@ -0,0 +1,9 @@ +package epp + +// Renew represents an EPP command as defined in RFC 5730. +// See https://www.rfc-editor.org/rfc/rfc5730.html#section-2.9.3.1. +type Renew struct { + // TODO: DomainRenew *domain.Renew +} + +func (Renew) eppCommand() {} diff --git a/internal/schema/epp/response.go b/internal/schema/epp/response.go new file mode 100644 index 0000000..5003430 --- /dev/null +++ b/internal/schema/epp/response.go @@ -0,0 +1,41 @@ +package epp + +// Response represents an EPP server as defined in RFC 5730. +// See https://www.rfc-editor.org/rfc/rfc5730.html#section-2.6. +type Response struct { + XMLName struct{} `xml:"urn:ietf:params:xml:ns:epp-1.0 response"` + + // Results contain one or more results (success or failure) of an EPP command. + Results []Result `xml:"result,omitempty"` + + // The OPTIONAL element describes messages queued for client + // retrieval. + MessageQueue *MessageQueue `xml:"msgQ"` + + // The (transaction identifier) element contains a client + // transaction ID of the command that elicited this response and a + // server transaction ID that uniquely identifies this response. + TransactionID TransactionID `xml:"trID"` +} + +func (Response) eppBody() {} + +// Result represents an EPP server as defined in RFC 5730. +type Result struct { + Code ResultCode `xml:"code"` + Message Message `xml:"message"` + // TODO: Values + ExtensionValues []ExtensionValue `xml:"extValue,omitempty"` +} + +// ExtensionValue represents an extension to an EPP command result. +type ExtensionValue struct { + // TODO: value + Reason Message `xml:"reason"` +} + +// TransactionID represents an EPP server as defined in RFC 5730. +type TransactionID struct { + Client string `xml:"clTRID"` + Server string `xml:"svTRID"` +} diff --git a/internal/schema/epp/response_test.go b/internal/schema/epp/response_test.go new file mode 100644 index 0000000..7333557 --- /dev/null +++ b/internal/schema/epp/response_test.go @@ -0,0 +1,141 @@ +package epp_test + +import ( + "testing" + + "github.com/domainr/epp/internal/schema/epp" + "github.com/domainr/epp/internal/schema/std" + "github.com/domainr/epp/internal/schema/test" +) + +func TestResponseRoundTrip(t *testing.T) { + tests := []struct { + name string + v interface{} + want string + wantErr bool + }{ + { + `empty `, + &epp.EPP{Body: &epp.Response{}}, + ``, + false, + }, + { + `simple code 1000`, + &epp.EPP{ + Body: &epp.Response{ + Results: []epp.Result{ + { + Code: epp.Success, + Message: epp.Success.Message(), + }, + }, + }, + }, + `1000Command completed successfully`, + false, + }, + { + `multiple result codes`, + &epp.EPP{ + Body: &epp.Response{ + Results: []epp.Result{ + { + Code: epp.ErrParameterRange, + Message: epp.ErrParameterRange.Message(), + }, + { + Code: epp.ErrParameterSyntax, + Message: epp.ErrParameterSyntax.Message(), + }, + }, + }, + }, + `2004Parameter value range error2005Parameter value syntax error`, + false, + }, + { + `with extValue>reason`, + &epp.EPP{ + Body: &epp.Response{ + Results: []epp.Result{ + { + Code: epp.ErrBillingFailure, + Message: epp.ErrBillingFailure.Message(), + ExtensionValues: []epp.ExtensionValue{ + { + Reason: epp.Message{Lang: "en", Value: "Command exceeds available balance"}, + }, + }, + }, + }, + }, + }, + `2104Billing failureCommand exceeds available balance`, + false, + }, + { + `with transaction IDs`, + &epp.EPP{ + Body: &epp.Response{ + Results: []epp.Result{ + { + Code: epp.Success, + Message: epp.Success.Message(), + }, + }, + TransactionID: epp.TransactionID{ + Client: "12345", + Server: "abcde", + }, + }, + }, + `1000Command completed successfully12345abcde`, + false, + }, + { + `with basic `, + &epp.EPP{ + Body: &epp.Response{ + MessageQueue: &epp.MessageQueue{Count: 5, ID: "67890"}, + }, + }, + ``, + false, + }, + { + `with with date`, + &epp.EPP{ + Body: &epp.Response{ + MessageQueue: &epp.MessageQueue{ + Count: 5, + ID: "67890", + Date: std.ParseTime("2000-01-01T00:00:00Z").Pointer(), + }, + }, + }, + `2000-01-01T00:00:00Z`, + false, + }, + { + `with full `, + &epp.EPP{ + Body: &epp.Response{ + MessageQueue: &epp.MessageQueue{ + Count: 5, + ID: "67890", + Date: std.ParseTime("2000-01-01T00:00:00Z").Pointer(), + }, + }, + }, + `2000-01-01T00:00:00Z`, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + test.RoundTrip(t, tt.v, tt.want, tt.wantErr) + }) + } +} diff --git a/internal/schema/epp/result_code.go b/internal/schema/epp/result_code.go new file mode 100644 index 0000000..71c10ba --- /dev/null +++ b/internal/schema/epp/result_code.go @@ -0,0 +1,161 @@ +package epp + +import "fmt" + +// ResultCode represents a 4-digit EPP result code. +// See https://tools.ietf.org/rfcmarkup?doc=5730#section-3. +// A ResultCode can be used as an error value. +// Note: only result codes >= 2000 are considered errors. +type ResultCode uint16 + +// Message returns a Message representation of c. +func (c ResultCode) Message() Message { + return Message{Lang: "en", Value: c.String()} +} + +// MarshalText implements encoding.TextMarshaler to print c as a 4-digit number. +func (c ResultCode) MarshalText() ([]byte, error) { + return []byte(fmt.Sprintf("%04d", c)), nil +} + +// IsError returns true if c represents an error code (>= 2000). +func (c ResultCode) IsError() bool { + return c >= 2000 +} + +// IsFatal returns true if c represents an error code that closes the +// connection. +func (c ResultCode) IsFatal() bool { + return c >= 2500 +} + +// Error returns the text representation of c if c is an error, or an empty +// string if c is a successful result code. +func (c ResultCode) Error() string { + if c.IsError() { + return c.String() + } + return "" +} + +// String returns the English text representation of c. +func (c ResultCode) String() string { + switch c { + case Success: + return "Command completed successfully" + case SuccessPending: + return "Command completed successfully; action pending" + case SuccessNoMessages: + return "Command completed successfully; no messages" + case SuccessAck: + return "Command completed successfully; ack to dequeue" + case SuccessEnd: + return "Command completed successfully; ending session" + case ErrUnknownCommand: + return "Unknown command" + case ErrCommandSyntax: + return "Command syntax error" + case ErrCommandUse: + return "Command use error" + case ErrRequiredParameter: + return "Required parameter missing" + case ErrParameterRange: + return "Parameter value range error" + case ErrParameterSyntax: + return "Parameter value syntax error" + case ErrUnimplementedVersion: + return "Unimplemented protocol version" + case ErrUnimplementedCommand: + return "Unimplemented command" + case ErrUnimplementedOption: + return "Unimplemented option" + case ErrUnimplementedExtension: + return "Unimplemented extension" + case ErrBillingFailure: + return "Billing failure" + case ErrNotEligbleForRenewal: + return "Object is not eligible for renewal" + case ErrNotEligibleForTransfer: + return "Object is not eligible for transfer" + case ErrAuthentication: + return "Authentication error" + case ErrAuthorization: + return "Authorization error" + case ErrInvalidAuthorization: + return "Invalid authorization information" + case ErrPendingTransfer: + return "Object pending transfer" + case ErrNotPendingTransfer: + return "Object not pending transfer" + case ErrExists: + return "Object exists" + case ErrDoesNotExist: + return "Object does not exist" + case ErrStatus: + return "Object status prohibits operation" + case ErrAssociation: + return "Object association prohibits operation" + case ErrParameterPolicy: + return "Parameter value policy error" + case ErrUnimplementedObject: + return "Unimplemented object service" + case ErrDataManagementViolation: + return "Data management policy violation" + case ErrCommandFailed: + return "Command failed" + case ErrCommandFailedClosing: + return "Command failed; server closing connection" + case ErrAuthenticationClosing: + return "Authentication error; server closing connection" + case ErrSessionLimitExceeded: + return "Session limit exceeded; server closing connection" + default: + return fmt.Sprintf("Status code %04d", c) + } +} + +const ( + ResultCodeMin ResultCode = 1000 + ResultCodeMax ResultCode = 2599 + + // This should match the number of known result codes below + KnownResultCodes = 34 + + // Success result codes + Success ResultCode = 1000 + SuccessPending ResultCode = 1001 + SuccessNoMessages ResultCode = 1300 + SuccessAck ResultCode = 1301 + SuccessEnd ResultCode = 1500 + + // Error result codes + ErrUnknownCommand ResultCode = 2000 + ErrCommandSyntax ResultCode = 2001 + ErrCommandUse ResultCode = 2002 + ErrRequiredParameter ResultCode = 2003 + ErrParameterRange ResultCode = 2004 + ErrParameterSyntax ResultCode = 2005 + ErrUnimplementedVersion ResultCode = 2100 + ErrUnimplementedCommand ResultCode = 2101 + ErrUnimplementedOption ResultCode = 2102 + ErrUnimplementedExtension ResultCode = 2103 + ErrBillingFailure ResultCode = 2104 + ErrNotEligbleForRenewal ResultCode = 2105 + ErrNotEligibleForTransfer ResultCode = 2106 + ErrAuthentication ResultCode = 2200 + ErrAuthorization ResultCode = 2201 + ErrInvalidAuthorization ResultCode = 2202 + ErrPendingTransfer ResultCode = 2300 + ErrNotPendingTransfer ResultCode = 2301 + ErrExists ResultCode = 2302 + ErrDoesNotExist ResultCode = 2303 + ErrStatus ResultCode = 2304 + ErrAssociation ResultCode = 2305 + ErrParameterPolicy ResultCode = 2306 + ErrUnimplementedObject ResultCode = 2307 + ErrDataManagementViolation ResultCode = 2308 + ErrCommandFailed ResultCode = 2400 + ErrCommandFailedClosing ResultCode = 2500 + ErrAuthenticationClosing ResultCode = 2501 + ErrSessionLimitExceeded ResultCode = 2502 +) diff --git a/internal/schema/epp/result_code_test.go b/internal/schema/epp/result_code_test.go new file mode 100644 index 0000000..51ba7d0 --- /dev/null +++ b/internal/schema/epp/result_code_test.go @@ -0,0 +1,64 @@ +package epp_test + +import ( + "strings" + "testing" + + "github.com/domainr/epp/internal/schema/epp" +) + +func TestResultCodeMessage(t *testing.T) { + for c := epp.ResultCodeMin; c <= epp.ResultCodeMax; c++ { + got := c.Message() + want := epp.Message{Lang: "en", Value: c.String()} + if got != want { + t.Errorf("epp.ResultCode(%04d).Message() = %v, want %v", c, got, want) + } + } +} + +func TestResultCodeIsError(t *testing.T) { + for c := epp.ResultCodeMin; c <= epp.ResultCodeMax; c++ { + got := c.IsError() + want := c >= 2000 + if got != want { + t.Errorf("epp.ResultCode(%04d).IsError() = %t, want %t", c, got, want) + } + } +} + +func TestResultCodeIsFatal(t *testing.T) { + for c := epp.ResultCodeMin; c <= epp.ResultCodeMax; c++ { + got := c.IsFatal() + want := c >= 2500 + if got != want { + t.Errorf("epp.ResultCode(%04d).IsFatal() = %t, want %t", c, got, want) + } + } +} + +func TestResultCodeError(t *testing.T) { + for c := epp.ResultCodeMin; c <= epp.ResultCodeMax; c++ { + gotErr := c.Error() != "" + wantErr := c.IsError() + if gotErr != wantErr { + var want string + if wantErr { + want = c.String() + } + t.Errorf("epp.ResultCode(%04d).Error() = %q, want %q", c, c.Error(), want) + } + } +} + +func TestResultCodeString(t *testing.T) { + var known int + for c := epp.ResultCodeMin; c <= epp.ResultCodeMax; c++ { + if !strings.HasPrefix(c.String(), "Status code ") { + known++ + } + } + if known != epp.KnownResultCodes { + t.Errorf("ResultCode values with known string values: %d, want %d", known, epp.KnownResultCodes) + } +} diff --git a/internal/schema/epp/service_extension.go b/internal/schema/epp/service_extension.go new file mode 100644 index 0000000..230df2f --- /dev/null +++ b/internal/schema/epp/service_extension.go @@ -0,0 +1,7 @@ +package epp + +// ServiceExtension represents an EPP element as defined in RFC 5730. +// Used in EPP and messages. +type ServiceExtension struct { + Extensions []string `xml:"extURI"` +} diff --git a/internal/schema/epp/transfer.go b/internal/schema/epp/transfer.go new file mode 100644 index 0000000..91a171f --- /dev/null +++ b/internal/schema/epp/transfer.go @@ -0,0 +1,9 @@ +package epp + +// Transfer represents an EPP command as defined in RFC 5730. +// See https://www.rfc-editor.org/rfc/rfc5730.html#section-2.9.2.4. +type Transfer struct { + // TODO: DomainTransfer *domain.Transfer +} + +func (Transfer) eppCommand() {} diff --git a/internal/schema/epp/update.go b/internal/schema/epp/update.go new file mode 100644 index 0000000..087bf43 --- /dev/null +++ b/internal/schema/epp/update.go @@ -0,0 +1,10 @@ +package epp + +// Update represents an EPP command as defined in RFC 5730. +// See https://www.rfc-editor.org/rfc/rfc5730.html#section-2.9.3.1. +type Update struct { + // TODO: DomainUpdate *domain.Update + // TODO: HostUpdate *host.Update +} + +func (Update) eppCommand() {} diff --git a/internal/schema/epp/version.go b/internal/schema/epp/version.go new file mode 100644 index 0000000..fedde40 --- /dev/null +++ b/internal/schema/epp/version.go @@ -0,0 +1,4 @@ +package epp + +// This package supports EPP version 1.0. +const Version = "1.0" diff --git a/internal/schema/host/ns.go b/internal/schema/host/ns.go new file mode 100644 index 0000000..a7bcbd0 --- /dev/null +++ b/internal/schema/host/ns.go @@ -0,0 +1,5 @@ +package host + +// Host defines the IETF URN for the EPP host namespace. +// See https://www.iana.org/assignments/xml-registry/ns/host-1.0.txt. +const NS = "urn:ietf:params:xml:ns:host-1.0" diff --git a/internal/schema/raw/xml.go b/internal/schema/raw/xml.go new file mode 100644 index 0000000..657b44d --- /dev/null +++ b/internal/schema/raw/xml.go @@ -0,0 +1,7 @@ +package raw + +// XML is a container for raw XML. Useful for single, self-closing tags, e.g.: +// . +type XML struct { + Value string `xml:",innerxml"` +} diff --git a/bool.go b/internal/schema/std/bool.go similarity index 86% rename from bool.go rename to internal/schema/std/bool.go index 293ccd3..b62d8d1 100644 --- a/bool.go +++ b/internal/schema/std/bool.go @@ -1,8 +1,8 @@ -package epp +package std -import "encoding/xml" +import "github.com/nbio/xml" -// Bool represents a bool that can be serialized to XML. +// Bool represents a boolean value that can be serialized to XML. // True: // False: (no tag) type Bool bool @@ -17,6 +17,7 @@ var ( // UnmarshalXML impements the xml.Unmarshaler interface. // Any tag present with this type = true. +// TODO: support false representation. func (b *Bool) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { var v struct{} d.DecodeElement(&v, &start) @@ -40,8 +41,7 @@ func (b *Bool) UnmarshalXMLAttr(attr *xml.Attr) error { // Any tag present with this type = true. func (b Bool) MarshalXML(e *xml.Encoder, start xml.StartElement) error { if b { - e.EncodeToken(start) - e.EncodeToken(xml.EndElement{Name: start.Name}) + e.EncodeToken(xml.SelfClosingElement(start)) } return nil } diff --git a/internal/schema/std/bool_test.go b/internal/schema/std/bool_test.go new file mode 100644 index 0000000..1bb00c5 --- /dev/null +++ b/internal/schema/std/bool_test.go @@ -0,0 +1,84 @@ +package std + +import ( + "testing" + + "github.com/domainr/epp/internal/schema/test" +) + +func TestBool(t *testing.T) { + type T1 struct { + XMLName struct{} `xml:"example"` + Fred Bool `xml:"fred"` + Jane Bool `xml:"jane"` + Susan Bool `xml:"susan"` + } + + type T2 struct { + XMLName struct{} `xml:"example,selfclosing"` + Fred Bool `xml:"fred,attr"` + Jane Bool `xml:"jane,attr,omitempty"` + Susan Bool `xml:"susan,attr,omitempty"` + } + + tests := []struct { + name string + v interface{} + want string + wantErr bool + }{ + { + `nil`, + nil, + ``, + false, + }, + { + `no tags`, + &T1{}, + ``, + false, + }, + { + `Fred`, + &T1{Fred: true}, + ``, + false, + }, + { + `Jane`, + &T1{Jane: true}, + ``, + false, + }, + { + `Fred and Susan`, + &T1{Fred: true, Susan: true}, + ``, + false, + }, + { + `Fred attribute`, + &T2{Fred: true}, + ``, + false, + }, + { + `Jane attribute`, + &T2{Jane: true}, + ``, + false, + }, + { + `Fred and Susan attributes`, + &T2{Fred: true, Susan: true}, + ``, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + test.RoundTrip(t, tt.v, tt.want, tt.wantErr) + }) + } +} diff --git a/internal/schema/std/duration.go b/internal/schema/std/duration.go new file mode 100644 index 0000000..802b9e1 --- /dev/null +++ b/internal/schema/std/duration.go @@ -0,0 +1,38 @@ +package std + +import ( + "time" + + "github.com/rickb777/date/period" +) + +// Duration represents W3C XML duration values. +// See https://www.w3.org/TR/xmlschema-2/#duration and https://www.rfc-editor.org/rfc/rfc3339.html. +type Duration struct { + time.Duration +} + +// ParseDuration parses an RFC 3339 duration string. +// It returns an empty value if unable to parse s. +func ParseDuration(s string) Duration { + p, _ := period.Parse(s) + d, _ := p.Duration() + return Duration{d} +} + +// Pointer returns a pointer to d, useful for declaring composite literals. +func (d Duration) Pointer() *Duration { + return &d +} + +// MarshalText implements encoding.TextMarshaler. +func (d *Duration) MarshalText() ([]byte, error) { + p, _ := period.NewOf(d.Duration) + return p.MarshalText() +} + +// UnmarshalText implements an encoding.TextUnmarshaler that ignores parsing errors. +func (d *Duration) UnmarshalText(text []byte) error { + *d = ParseDuration(string(text)) + return nil +} diff --git a/internal/schema/std/element.go b/internal/schema/std/element.go new file mode 100644 index 0000000..62b509f --- /dev/null +++ b/internal/schema/std/element.go @@ -0,0 +1,10 @@ +package std + +import "github.com/nbio/xml" + +// Element is a generic XML element, used for marshaling +// other types into an XML wrapper. +type Element struct { + XMLName xml.Name + Contents []interface{} +} diff --git a/internal/schema/std/string.go b/internal/schema/std/string.go new file mode 100644 index 0000000..feb8ef9 --- /dev/null +++ b/internal/schema/std/string.go @@ -0,0 +1,7 @@ +package std + +// StringPointer returns a pointer to s. +// Used for declaring a pointer to a string literal. +func StringPointer(s string) *string { + return &s +} diff --git a/internal/schema/std/template.go b/internal/schema/std/template.go new file mode 100644 index 0000000..6e23ac9 --- /dev/null +++ b/internal/schema/std/template.go @@ -0,0 +1,86 @@ +package std + +import ( + "io" + "reflect" + "sync" + + "github.com/nbio/xml" +) + +// Template maps xml.Name values to Go types. This allows decoding XML into a +// Go struct with one or more interface{} fields, which would otherwise be skipped. +type Template struct { + types sync.Map +} + +// Add maps name to template value v, which must be a pointer to a concrete type. +// If v is nil or points to a nil type, Add will silently fail. +func (d *Template) Add(name xml.Name, v interface{}) { + t := reflect.TypeOf(v) + if t == nil { + return + } + for t.Kind() == reflect.Ptr { + t = t.Elem() + if t == nil { + return + } + } + d.types.Store(name, t) +} + +// Type returns a reflect.Type for name. +// Returns nil if name is not mapped. +func (t *Template) Type(name xml.Name) reflect.Type { + v, ok := t.types.Load(name) + if !ok { + return nil + } + return v.(reflect.Type) +} + +// New returns a new instance of the type that matches xml.Name. +// Returns nil if the name does not have a type associated with it. +func (t *Template) New(name xml.Name) interface{} { + typ := t.Type(name) + if typ == nil { + return nil + } + return reflect.New(typ).Interface() +} + +// DecodeElement attempts to decode the start element using its internal map of xml.Name to reflect.Type. +// It will silently skip unknown tags and return any XML parsing errors encountered. +func (t *Template) DecodeElement(xd *xml.Decoder, start *xml.StartElement) (interface{}, error) { + v := t.New(start.Name) + if v == nil { + // Silently skip unknown tags. + return nil, nil + } + err := xd.DecodeElement(v, start) + return v, err +} + +// DecodeChildren attempts to decode the immediate child elements of start. +// It only evaluates start elements and ignores unknown tags. +func (t *Template) DecodeChildren(d *xml.Decoder, start *xml.StartElement) ([]interface{}, error) { + var values []interface{} + for { + tok, err := d.Token() + if err == io.EOF { + break + } + if err != nil { + return values, err + } + if start, ok := tok.(xml.StartElement); ok { + v, err := t.DecodeElement(d, &start) + if err != nil { + return values, err + } + values = append(values, v) + } + } + return values, nil +} diff --git a/internal/schema/std/time.go b/internal/schema/std/time.go new file mode 100644 index 0000000..f31f049 --- /dev/null +++ b/internal/schema/std/time.go @@ -0,0 +1,37 @@ +package std + +import ( + "time" +) + +// Time represents an W3C XML date-time value. +// See https://www.w3.org/TR/xmlschema-2/#dateTime and https://www.rfc-editor.org/rfc/rfc3339.html. +type Time struct { + time.Time +} + +// ParseTime parses an RFC 3339 date-time string. +// It returns an empty value if unable to parse s. +func ParseTime(s string) Time { + tt, _ := time.Parse(time.RFC3339, s) + return Time{tt} +} + +// Pointer returns a pointer to t, useful for declaring composite literals. +func (t Time) Pointer() *Time { + return &t +} + +// MarshalText implements encoding.TextMarshaler. +func (t *Time) MarshalText() ([]byte, error) { + if t == nil { + return nil, nil + } + return t.Time.MarshalText() +} + +// UnmarshalText implements an encoding.TextUnmarshaler that ignores parsing errors. +func (t *Time) UnmarshalText(text []byte) error { + _ = t.Time.UnmarshalText(text) + return nil +} diff --git a/internal/schema/std/time_test.go b/internal/schema/std/time_test.go new file mode 100644 index 0000000..2b04a28 --- /dev/null +++ b/internal/schema/std/time_test.go @@ -0,0 +1,64 @@ +package std + +import ( + "testing" + "time" + + "github.com/domainr/epp/internal/schema/test" +) + +func TestTime(t *testing.T) { + may19, err := time.Parse(time.RFC3339, "2015-05-19T06:34:21.1Z") + if err != nil { + t.Fatal(err) + } + + type T struct { + XMLName struct{} `xml:"example"` + Value *Time `xml:"when"` + Attr *Time `xml:"when,attr,omitempty"` + } + + tests := []struct { + name string + v interface{} + want string + wantErr bool + }{ + { + `no tags`, + &T{}, + ``, + false, + }, + { + `zero value chardata`, + &T{Value: &Time{}}, + `0001-01-01T00:00:00Z`, + false, + }, + { + `zero value attr`, + &T{Attr: &Time{}}, + ``, + false, + }, + { + `chardata`, + &T{Value: &Time{may19}}, + `2015-05-19T06:34:21.1Z`, + false, + }, + { + `attr`, + &T{Attr: &Time{may19}}, + ``, + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + test.RoundTrip(t, tt.v, tt.want, tt.wantErr) + }) + } +} diff --git a/internal/schema/std/value.go b/internal/schema/std/value.go new file mode 100644 index 0000000..f4bf028 --- /dev/null +++ b/internal/schema/std/value.go @@ -0,0 +1,21 @@ +package std + +// XMLValuer is the interface implemented by types that need to modify their +// representation before being marshaled into or from XML. +// +// XMLValue returns a value that can be marshaled to or unmarshaled from XML. It +// will be passed a single argument, which is the value being marshaled or +// unmarshaled. If an XMLValuer is embedded in another struct, XMLValue will be +// called with a pointer to the outer struct. +type XMLValuer interface { + XMLValue(interface{}) interface{} +} + +// XMLValue will return v.XMLValue(v) if v implements XMLValuer. +// If v does not implement XMLValuer, it will return v. +func XMLValue(v interface{}) interface{} { + if v, ok := v.(XMLValuer); ok { + return v.XMLValue(v) + } + return v +} diff --git a/internal/schema/test/test.go b/internal/schema/test/test.go new file mode 100644 index 0000000..3ff5aed --- /dev/null +++ b/internal/schema/test/test.go @@ -0,0 +1,70 @@ +package test + +import ( + "bytes" + "reflect" + "testing" + + "github.com/nbio/xml" +) + +// RoundTrip validates if v marshals to want or wantErr (if set), +// and the resulting XML unmarshals to v. +func RoundTrip(t *testing.T, v interface{}, want string, wantErr bool) { + x, err := xml.Marshal(v) + if (err != nil) != wantErr { + t.Errorf("xml.Marshal() error = %v, wantErr %v", err, wantErr) + return + } + if string(x) != want { + t.Errorf("xml.Marshal()\nGot: %v\nWant: %v", string(x), want) + } + + if v == nil { + return + } + + i := reflect.New(reflect.TypeOf(v).Elem()).Interface() + err = xml.Unmarshal(x, i) + if err != nil { + t.Errorf("xml.Unmarshal() error = %v", err) + return + } + if !reflect.DeepEqual(v, v) { + t.Errorf("xml.Unmarshal()\nGot: %#v\nWant: %#v", i, v) + } +} + +// RoundTripName validates if v marshals to want or wantErr (if set), +// and the resulting XML unmarshals to v. The outer XML tag will use name, if set. +func RoundTripName(t *testing.T, name xml.Name, v interface{}, want string, wantErr bool) { + var err error + buf := &bytes.Buffer{} + enc := xml.NewEncoder(buf) + if name == (xml.Name{}) { + err = enc.Encode(v) + } else { + err = enc.EncodeElement(v, xml.StartElement{Name: name}) + } + if (err != nil) != wantErr { + t.Errorf("XML encoding error = %v, wantErr %v", err, wantErr) + return + } + if buf.String() != want { + t.Errorf("XML encoding\nGot: %v\nWant: %v", buf.String(), want) + } + + if v == nil { + return + } + + i := reflect.New(reflect.TypeOf(v).Elem()).Interface() + err = xml.Unmarshal(buf.Bytes(), i) + if err != nil { + t.Errorf("xml.Unmarshal() error = %v", err) + return + } + if !reflect.DeepEqual(v, v) { + t.Errorf("xml.Unmarshal()\nGot: %#v\nWant: %#v", i, v) + } +} diff --git a/logging.go b/logging.go deleted file mode 100644 index 3b730b4..0000000 --- a/logging.go +++ /dev/null @@ -1,47 +0,0 @@ -package epp - -import ( - "bytes" - "encoding/xml" - "fmt" - "io" -) - -// DebugLogger is an io.Writer. Set to enable logging of EPP message XML. -var DebugLogger io.Writer - -func logXML(pfx string, p []byte) { - if DebugLogger == nil { - return - } - - var b bytes.Buffer - enc := xml.NewEncoder(&b) - enc.Indent("", "\t") - - dec := xml.NewDecoder(bytes.NewReader(p)) - var t xml.Token - var err error - for { - t, err = dec.RawToken() - if err == io.EOF { - err = enc.Flush() - break - } - if err != nil { - break - } - err = enc.EncodeToken(t) - if err != nil { - break - } - } - if err != nil { - fmt.Fprintf(DebugLogger, "Indentation error. Raw XML: %s\n%s\n\n", pfx, string(p)) - return - } - - fmt.Fprintf(DebugLogger, "%s (pretty-printed)\n", pfx) - io.Copy(DebugLogger, &b) - fmt.Fprint(DebugLogger, "\n\n") -} diff --git a/ns/ns.go b/ns/ns.go new file mode 100644 index 0000000..b4d0e85 --- /dev/null +++ b/ns/ns.go @@ -0,0 +1,55 @@ +package ns + +import ( + "github.com/domainr/epp/internal/schema/common" + "github.com/domainr/epp/internal/schema/contact" + "github.com/domainr/epp/internal/schema/domain" + "github.com/domainr/epp/internal/schema/epp" + "github.com/domainr/epp/internal/schema/host" +) + +const ( + // EPP defines the IETF URN for the EPP namespace. + // See https://www.iana.org/assignments/xml-registry/ns/epp-1.0.txt. + EPP = epp.NS + + // Common defines the IETF URN for the EPP common namespace. + // See https://www.iana.org/assignments/xml-registry/ns/eppcom-1.0.txt. + Common = common.NS + + // Host defines the IETF URN for the EPP contact namespace. + // See https://www.iana.org/assignments/xml-registry/ns/contact-1.0.txt. + Contact = contact.NS + + // Domain defines the IETF URN for the EPP domain namespace. + // See https://www.iana.org/assignments/xml-registry/ns/domain-1.0.txt + // and https://datatracker.ietf.org/doc/html/rfc5731. + Domain = domain.NS + + // Host defines the IETF URN for the EPP host namespace. + // See https://www.iana.org/assignments/xml-registry/ns/host-1.0.txt. + Host = host.NS + + // SecDNS defines the IETF URN for the EPP DNSSEC namespace. + // See https://datatracker.ietf.org/doc/html/rfc5910. + SecDNS = "urn:ietf:params:xml:ns:secDNS-1.1" + + Fee05 = "urn:ietf:params:xml:ns:fee-0.5" + Fee06 = "urn:ietf:params:xml:ns:fee-0.6" + Fee07 = "urn:ietf:params:xml:ns:fee-0.7" + Fee08 = "urn:ietf:params:xml:ns:fee-0.8" + Fee09 = "urn:ietf:params:xml:ns:fee-0.9" + Fee10 = "urn:ietf:params:xml:ns:epp:fee-1.0" + Fee11 = "urn:ietf:params:xml:ns:fee-0.11" + Fee21 = "urn:ietf:params:xml:ns:fee-0.21" + IDN = "urn:ietf:params:xml:ns:idn-1.0" + Launch = "urn:ietf:params:xml:ns:launch-1.0" + Neulevel = "urn:ietf:params:xml:ns:neulevel" + Neulevel10 = "urn:ietf:params:xml:ns:neulevel-1.0" + Price = "urn:ar:params:xml:ns:price-1.1" + RGP = "urn:ietf:params:xml:ns:rgp-1.0" + + Finance = "http://www.unitedtld.com/epp/finance-1.0" + Charge = "http://www.unitedtld.com/epp/charge-1.0" + Namestore = "http://www.verisign-grs.com/epp/namestoreExt-1.1" +) diff --git a/response.go b/response.go deleted file mode 100644 index cdaea89..0000000 --- a/response.go +++ /dev/null @@ -1,20 +0,0 @@ -package epp - -import "github.com/nbio/xx" - -// Response represents an EPP response. -type Response struct { - Result - Greeting - DomainCheckResponse - DomainInfoResponse -} - -var scanResponse = xx.NewScanner() - -func init() { - scanResponse.MustHandleStartElement("epp", func(c *xx.Context) error { - *c.Value.(*Response) = Response{} - return nil - }) -} diff --git a/result.go b/result.go deleted file mode 100644 index b38408f..0000000 --- a/result.go +++ /dev/null @@ -1,49 +0,0 @@ -package epp - -import ( - "fmt" - - "github.com/nbio/xx" -) - -// Result represents an EPP element. -type Result struct { - Code int `xml:"code,attr"` - Message string `xml:"msg"` - Reason string `xml:"extValue>reason,omitempty"` -} - -// IsError determines whether an EPP status code is an error. -// https://tools.ietf.org/html/rfc5730#section-3 -func (r *Result) IsError() bool { - return r.Code >= 2000 -} - -// IsFatal determines whether an EPP status code is a fatal response, -// and the connection should be closed. -// https://tools.ietf.org/html/rfc5730#section-3 -func (r *Result) IsFatal() bool { - return r.Code >= 2500 -} - -// Error implements the error interface. -func (r *Result) Error() string { - return fmt.Sprintf("EPP result code %d: %s", r.Code, r.Message) -} - -func init() { - path := "epp > response > result" - scanResponse.MustHandleStartElement(path, func(c *xx.Context) error { - res := c.Value.(*Response) - res.Result.Code = c.AttrInt("", "code") - return nil - }) - scanResponse.MustHandleCharData(path+"> msg", func(c *xx.Context) error { - c.Value.(*Response).Result.Message = string(c.CharData) - return nil - }) - scanResponse.MustHandleCharData(path+"> extValue > reason", func(c *xx.Context) error { - c.Value.(*Response).Result.Reason = string(c.CharData) - return nil - }) -} diff --git a/result_test.go b/result_test.go deleted file mode 100644 index 39286d0..0000000 --- a/result_test.go +++ /dev/null @@ -1,58 +0,0 @@ -package epp - -import ( - "testing" - - "github.com/nbio/st" -) - -func TestScanResult(t *testing.T) { - var res Response - r := &res.Result - - d := decoder(`Command completed successfully`) - err := IgnoreEOF(scanResponse.Scan(d, &res)) - st.Expect(t, err, nil) - st.Expect(t, r.Code, 1000) - st.Expect(t, r.Message, "Command completed successfully") - st.Expect(t, r.IsError(), false) - st.Expect(t, r.IsFatal(), false) - - // Result code >= 2000 is an error. - d = decoder(`Command syntax error`) - err = IgnoreEOF(scanResponse.Scan(d, &res)) - st.Expect(t, err, nil) - st.Expect(t, r.Code, 2001) - st.Expect(t, r.Message, "Command syntax error") - st.Expect(t, r.IsError(), true) - st.Expect(t, r.IsFatal(), false) - - // Result code 2306 is a policy error. - d = decoder(`Parameter value policy errorThe label is too short`) - err = IgnoreEOF(scanResponse.Scan(d, &res)) - st.Expect(t, err, nil) - st.Expect(t, r.Code, 2306) - st.Expect(t, r.Message, "Parameter value policy error") - st.Expect(t, r.Reason, "The label is too short") - st.Expect(t, r.IsError(), true) - st.Expect(t, r.IsFatal(), false) - - // Result code > 2500 is a fatal error. - d = decoder(`Authentication error; server closing connection`) - err = IgnoreEOF(scanResponse.Scan(d, &res)) - st.Expect(t, err, nil) - st.Expect(t, r.Code, 2501) - st.Expect(t, r.Message, "Authentication error; server closing connection") - st.Expect(t, r.IsError(), true) - st.Expect(t, r.IsFatal(), true) -} - -func BenchmarkScanResult(b *testing.B) { - for i := 0; i < b.N; i++ { - b.StopTimer() - d := decoder(`Command completed successfully`) - b.StartTimer() - var res Response - scanResponse.Scan(d, &res) - } -} diff --git a/server.go b/server.go new file mode 100644 index 0000000..58b68e5 --- /dev/null +++ b/server.go @@ -0,0 +1,38 @@ +package epp + +import ( + "context" + + "github.com/domainr/epp/internal/schema/epp" +) + +// Server is an EPP version 1.0 server. +type Server struct { + // Name is the name of this EPP server. It is sent to clients in a EPP + // message. If empty, a reasonable default will be used. + Name string + + // Config describes the EPP server configuration. Configuration + // parameters are announced to EPP clients in an EPP message. + Config Config + + // Handler is called in a goroutine for each incoming EPP connection. + // The connection will be closed when Handler returns. + Handler func(Session) error +} + +type Session interface { + // Context returns the connection Context for this session. The Context + // will be canceled if the underlying Transport goes away or is closed. + Context() context.Context + + // ReadCommand reads the next EPP command from the client. An error will + // be returned if the underlying connection is closed or an error occurs + // reading from the connection. + ReadCommand() (*epp.Command, error) + + // WriteResponse sends an EPP response to the client. An error will + // be returned if the underlying connection is closed or an error occurs + // writing to the connection. + WriteResponse(*epp.Response) error +} diff --git a/session.go b/session.go deleted file mode 100644 index 7c5f4cd..0000000 --- a/session.go +++ /dev/null @@ -1,90 +0,0 @@ -package epp - -import ( - "bytes" - "encoding/xml" -) - -// Login initializes an authenticated EPP session. -// https://tools.ietf.org/html/rfc5730#section-2.9.1.1 -func (c *Conn) Login(user, password, newPassword string) error { - err := c.writeLogin(user, password, newPassword) - if err != nil { - return err - } - res, err := c.readResponse() - if err != nil { - return nil - } - // We always have a .Result in our non-pointer, but it might be meaningless. - // We might not have read anything. We think that the worst case is we - // have the same zero values we'd get without the assignment-even-in-error-case. - c.m.Lock() - c.LoginResult = res.Result - c.m.Unlock() - return err -} - -func (c *Conn) writeLogin(user, password, newPassword string) error { - ver, lang := "1.0", "en" - if len(c.Greeting.Versions) > 0 { - ver = c.Greeting.Versions[0] - } - if len(c.Greeting.Languages) > 0 { - lang = c.Greeting.Languages[0] - } - x, err := encodeLogin(user, password, newPassword, ver, lang, c.Greeting.Objects, c.Greeting.Extensions) - if err != nil { - return err - } - return c.writeRequest(x) -} - -func encodeLogin(user, password, newPassword, version, language string, objects, extensions []string) ([]byte, error) { - buf := bytes.NewBufferString(xmlCommandPrefix) - buf.WriteString(``) - xml.EscapeText(buf, []byte(user)) - buf.WriteString(``) - xml.EscapeText(buf, []byte(password)) - if len(newPassword) > 0 { - buf.WriteString(``) - xml.EscapeText(buf, []byte(newPassword)) - buf.WriteString(``) - } else { - buf.WriteString(``) - } - xml.EscapeText(buf, []byte(version)) - buf.WriteString(``) - xml.EscapeText(buf, []byte(language)) - buf.WriteString(``) - for _, o := range objects { - buf.WriteString(``) - xml.EscapeText(buf, []byte(o)) - buf.WriteString(``) - } - if len(extensions) > 0 { - buf.WriteString(``) - for _, o := range extensions { - buf.WriteString(``) - xml.EscapeText(buf, []byte(o)) - buf.WriteString(``) - } - buf.WriteString(``) - } - buf.WriteString(``) - buf.WriteString(xmlCommandSuffix) - return buf.Bytes(), nil -} - -// Logout sends a command to terminate an EPP session. -// https://tools.ietf.org/html/rfc5730#section-2.9.1.2 -func (c *Conn) Logout() error { - err := c.writeRequest(xmlLogout) - if err != nil { - return err - } - _, err = c.readResponse() - return err -} - -var xmlLogout = []byte(xmlCommandPrefix + `` + xmlCommandSuffix) diff --git a/session_test.go b/session_test.go deleted file mode 100644 index cadc8b0..0000000 --- a/session_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package epp - -import ( - "encoding/xml" - "testing" - - "github.com/nbio/st" -) - -func TestEncodeLogin(t *testing.T) { - x, err := encodeLogin("jane", "battery", "", "1.0", "en", nil, nil) - st.Expect(t, err, nil) - st.Expect(t, string(x), ` -janebattery1.0en`) - var v struct{} - err = xml.Unmarshal(x, &v) - st.Expect(t, err, nil) -} - -func TestEncodeLoginChangePassword(t *testing.T) { - x, err := encodeLogin("jane", "battery", "horse", "1.0", "en", nil, nil) - st.Expect(t, err, nil) - st.Expect(t, string(x), ` -janebatteryhorse1.0en`) - var v struct{} - err = xml.Unmarshal(x, &v) - st.Expect(t, err, nil) -} - -var ( - testObjects = []string{ - ObjContact, - ObjDomain, - ObjFinance, - ObjHost, - } - testExtensions = []string{ - ExtCharge, - ExtFee05, - ExtFee06, - ExtIDN, - ExtLaunch, - ExtRGP, - ExtSecDNS, - } -) - -func BenchmarkEncodeLogin(b *testing.B) { - for i := 0; i < b.N; i++ { - encodeLogin("jane", "battery", "horse", "1.0", "en", testObjects, testExtensions) - } -} diff --git a/status.go b/status.go deleted file mode 100644 index 364f593..0000000 --- a/status.go +++ /dev/null @@ -1,108 +0,0 @@ -package epp - -// Status represents EPP status codes as a bitfield. -// https://www.icann.org/resources/pages/epp-status-codes-2014-06-16-en -// https://tools.ietf.org/html/std69 -// https://tools.ietf.org/html/rfc3915 -type Status uint32 - -// Status types, in order of priority, low to high. -// Status are stored in a single integer as a bit field. -const ( - StatusUnknown Status = iota - - // Server status codes set by a domain registry - StatusOK Status = 1 << (iota - 1) // Standard status for a domain, meaning it has no pending operations or prohibitions. - StatusLinked - StatusAddPeriod // This grace period is provided after the initial registration of a domain name. If the registrar deletes the domain name during this period, the registry may provide credit to the registrar for the cost of the registration. - StatusAutoRenewPeriod - StatusInactive - StatusPendingCreate - StatusPendingDelete - StatusPendingRenew - StatusPendingRestore - StatusPendingTransfer - StatusPendingUpdate - StatusRedemptionPeriod - StatusRenewPeriod - StatusServerDeleteProhibited - StatusServerHold - StatusServerRenewProhibited - StatusServerTransferProhibited - StatusServerUpdateProhibited - StatusTransferPeriod - StatusClientDeleteProhibited - StatusClientHold - StatusClientRenewProhibited - StatusClientTransferProhibited - StatusClientUpdateProhibited - - // RDAP status codes map roughly, but not exactly to EPP status codes. - // https://tools.ietf.org/html/rfc8056#section-2 - StatusActive = StatusOK - StatusAssociated = StatusLinked - - // StatusClient are status codes set by a domain registrar. - StatusClient = StatusClientDeleteProhibited | StatusClientHold | StatusClientRenewProhibited | StatusClientTransferProhibited | StatusClientUpdateProhibited -) - -// stringToStatus maps EPP and RDAP status strings to Status bits. -var stringToStatus = map[string]Status{ - "ok": StatusOK, - "active": StatusActive, - "linked": StatusLinked, - "associated": StatusAssociated, - "add period": StatusAddPeriod, - "addPeriod": StatusAddPeriod, - "auto renew period": StatusAutoRenewPeriod, - "autoRenewPeriod": StatusAutoRenewPeriod, - "inactive": StatusInactive, - "pending create": StatusPendingCreate, - "pendingCreate": StatusPendingCreate, - "pending delete": StatusPendingDelete, - "pendingDelete": StatusPendingDelete, - "pending renew": StatusPendingRenew, - "pendingRenew": StatusPendingRenew, - "pending restore": StatusPendingRestore, - "pendingRestore": StatusPendingRestore, - "pending transfer": StatusPendingTransfer, - "pendingTransfer": StatusPendingTransfer, - "pending update": StatusPendingUpdate, - "pendingUpdate": StatusPendingUpdate, - "redemption period": StatusRedemptionPeriod, - "redemptionPeriod": StatusRedemptionPeriod, - "renew period": StatusRenewPeriod, - "renewPeriod": StatusRenewPeriod, - "server delete prohibited": StatusServerDeleteProhibited, - "serverDeleteProhibited": StatusServerDeleteProhibited, - "server hold": StatusServerHold, - "serverHold": StatusServerHold, - "server renew prohibited": StatusServerRenewProhibited, - "serverRenewProhibited": StatusServerRenewProhibited, - "server transfer prohibited": StatusServerTransferProhibited, - "serverTransferProhibited": StatusServerTransferProhibited, - "server update prohibited": StatusServerUpdateProhibited, - "serverUpdateProhibited": StatusServerUpdateProhibited, - "transfer period": StatusTransferPeriod, - "transferPeriod": StatusTransferPeriod, - "client delete prohibited": StatusClientDeleteProhibited, - "clientDeleteProhibited": StatusClientDeleteProhibited, - "client hold": StatusClientHold, - "clientHold": StatusClientHold, - "client renew prohibited": StatusClientRenewProhibited, - "clientRenewProhibited": StatusClientRenewProhibited, - "client transfer prohibited": StatusClientTransferProhibited, - "clientTransferProhibited": StatusClientTransferProhibited, - "client update prohibited": StatusClientUpdateProhibited, - "clientUpdateProhibited": StatusClientUpdateProhibited, -} - -// ParseStatus returns a Status from one or more strings. -// It does not attempt to validate the input or resolve conflicting status bits. -func ParseStatus(in ...string) Status { - var s Status - for _, v := range in { - s |= stringToStatus[v] - } - return s -} diff --git a/status/code.go b/status/code.go new file mode 100644 index 0000000..eb471ea --- /dev/null +++ b/status/code.go @@ -0,0 +1,117 @@ +package status + +// Code represents EPP status codes as a bitfield. +// See https://tools.ietf.org/html/std69, https://tools.ietf.org/html/rfc3915, +// and https://www.icann.org/resources/pages/epp-status-codes-2014-06-16-en. +type Code uint32 + +// EPP status codes, in order of priority, from low to high. Codes are stored in +// a single integer as a bit field. +const ( + Unknown Code = iota + + // OK is the default status code for a domain, meaning it has no pending operations or + // prohibitions. + OK Code = 1 << (iota - 1) + Linked + + // This grace period is provided after the initial registration of a + // domain name. If the registrar deletes the domain name during this + // period, the registry may provide credit to the registrar for the cost + // of the registration. + AddPeriod + AutoRenewPeriod + Inactive + PendingCreate + PendingDelete + PendingRenew + PendingRestore + PendingTransfer + PendingUpdate + RedemptionPeriod + RenewPeriod + ServerDeleteProhibited + ServerHold + ServerRenewProhibited + ServerTransferProhibited + ServerUpdateProhibited + TransferPeriod + ClientDeleteProhibited + ClientHold + ClientRenewProhibited + ClientTransferProhibited + ClientUpdateProhibited + + // RDAP status codes loosely map to EPP status codes. + // See https://tools.ietf.org/html/rfc8056#section-2. + Active = OK + Associated = Linked + + // ClientCodes are status codes set by a domain registrar. + ClientCodes = ClientDeleteProhibited | + ClientHold | + ClientRenewProhibited | + ClientTransferProhibited | + ClientUpdateProhibited +) + +// stringToCode maps EPP and RDAP status strings to Codes. +var stringToCode = map[string]Code{ + "ok": OK, + "active": Active, + "linked": Linked, + "associated": Associated, + "add period": AddPeriod, + "addPeriod": AddPeriod, + "auto renew period": AutoRenewPeriod, + "autoRenewPeriod": AutoRenewPeriod, + "inactive": Inactive, + "pending create": PendingCreate, + "pendingCreate": PendingCreate, + "pending delete": PendingDelete, + "pendingDelete": PendingDelete, + "pending renew": PendingRenew, + "pendingRenew": PendingRenew, + "pending restore": PendingRestore, + "pendingRestore": PendingRestore, + "pending transfer": PendingTransfer, + "pendingTransfer": PendingTransfer, + "pending update": PendingUpdate, + "pendingUpdate": PendingUpdate, + "redemption period": RedemptionPeriod, + "redemptionPeriod": RedemptionPeriod, + "renew period": RenewPeriod, + "renewPeriod": RenewPeriod, + "server delete prohibited": ServerDeleteProhibited, + "serverDeleteProhibited": ServerDeleteProhibited, + "server hold": ServerHold, + "serverHold": ServerHold, + "server renew prohibited": ServerRenewProhibited, + "serverRenewProhibited": ServerRenewProhibited, + "server transfer prohibited": ServerTransferProhibited, + "serverTransferProhibited": ServerTransferProhibited, + "server update prohibited": ServerUpdateProhibited, + "serverUpdateProhibited": ServerUpdateProhibited, + "transfer period": TransferPeriod, + "transferPeriod": TransferPeriod, + "client delete prohibited": ClientDeleteProhibited, + "clientDeleteProhibited": ClientDeleteProhibited, + "client hold": ClientHold, + "clientHold": ClientHold, + "client renew prohibited": ClientRenewProhibited, + "clientRenewProhibited": ClientRenewProhibited, + "client transfer prohibited": ClientTransferProhibited, + "clientTransferProhibited": ClientTransferProhibited, + "client update prohibited": ClientUpdateProhibited, + "clientUpdateProhibited": ClientUpdateProhibited, +} + +// Parse returns a status code from one or more strings. It does not attempt to +// validate the input or resolve conflicting status bits. +func Parse(in ...string) Code { + var s Code + for _, v := range in { + s |= stringToCode[v] + } + return s +} diff --git a/time.go b/time.go deleted file mode 100644 index e6dfe11..0000000 --- a/time.go +++ /dev/null @@ -1,24 +0,0 @@ -package epp - -import ( - "encoding/xml" - "time" -) - -// Time represents EPP date-time values, serialized to XML in RFC-3339 format. -// Because the default encoding.TextMarshaler implementation in time.Time uses -// RFC-3339, we don’t need to create a custom marshaler for this type. -type Time struct { - time.Time -} - -// UnmarshalXML implements a custom XML unmarshaler that ignores time parsing errors. -// http://stackoverflow.com/a/25015260 -func (t *Time) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { - var v string - d.DecodeElement(&v, &start) - if tt, err := time.Parse(time.RFC3339, v); err == nil { - *t = Time{tt} - } - return nil -} diff --git a/time_test.go b/time_test.go deleted file mode 100644 index 18f19df..0000000 --- a/time_test.go +++ /dev/null @@ -1,28 +0,0 @@ -package epp - -import ( - "encoding/xml" - "testing" - "time" - - "github.com/nbio/st" -) - -func TestTime(t *testing.T) { - x := []byte(`2015-05-19T06:34:21.1Z`) - var y struct { - XMLName struct{} `xml:"example"` - When Time `xml:"when"` - } - - err := xml.Unmarshal(x, &y) - st.Expect(t, err, nil) - tt, _ := time.Parse(time.RFC3339, "2015-05-19T06:34:21.1Z") - st.Expect(t, y.When, Time{tt}) - z, err := xml.Marshal(&y) - st.Expect(t, err, nil) - st.Expect(t, string(z), string(x)) - text, err := y.When.MarshalText() - st.Expect(t, err, nil) - st.Expect(t, string(text), "2015-05-19T06:34:21.1Z") -} diff --git a/transaction.go b/transaction.go new file mode 100644 index 0000000..b022adc --- /dev/null +++ b/transaction.go @@ -0,0 +1,25 @@ +package epp + +import ( + "context" + + "github.com/domainr/epp/internal/schema/epp" +) + +type transaction struct { + ctx context.Context + reply chan reply +} + +func newTransaction(ctx context.Context) (transaction, context.CancelFunc) { + ctx, cancel := context.WithCancel(ctx) + return transaction{ + ctx: ctx, + reply: make(chan reply), + }, cancel +} + +type reply struct { + body epp.Body + err error +} diff --git a/transport.go b/transport.go new file mode 100644 index 0000000..6c9e2fc --- /dev/null +++ b/transport.go @@ -0,0 +1,377 @@ +package epp + +import ( + "context" + "fmt" + "net" + "sync" + "sync/atomic" + "time" + + "github.com/nbio/xml" + + "github.com/domainr/epp/internal/schema/epp" +) + +// Transport is a low-level client for the Extensible Provisioning Protocol (EPP) +// as defined in RFC 3790. See https://www.rfc-editor.org/rfc/rfc5730.html. +// A Transport is safe to use from multiple goroutines. +type Transport interface { + // Command sends an EPP command and returns an EPP response. + // It blocks until a response is received, ctx is canceled, or + // the underlying connection is closed. + // + // The EPP command must have a valid, unique transaction ID to correlate + // it with a response. + // TODO: should it assign a transaction ID if empty? + Command(ctx context.Context, cmd *epp.Command) (*epp.Response, error) + + // Hello sends an EPP and returns the received. + // It blocks until a is received, ctx is canceled, or + // the underlying connection is closed. + Hello(ctx context.Context) (*epp.Greeting, error) + + // Greeting returns the last recieved from the server. + // It blocks until the is received, ctx is canceled, or + // the underlying connection is closed. + Greeting(ctx context.Context) (*epp.Greeting, error) + + // Close closes the connection. + Close() error +} + +type transport struct { + // mWrite protects writes on t. + mWrite sync.Mutex + conn Conn + + // greeting stores the most recently received from the server. + greeting atomic.Value + + // hasGreeting is closed when the client receives an initial from the server. + hasGreeting chan struct{} + + mHellos sync.Mutex + hellos []transaction + + mCommands sync.Mutex + commands map[string]transaction + + // done is closed when the client receives a fatal error or the connection is closed. + done chan struct{} +} + +// NewTransport returns a new Transport using conn. +func NewTransport(conn Conn) Transport { + t := newTransport(conn) + go t.readLoop() + return t +} + +func newTransport(conn Conn) *transport { + return &transport{ + conn: conn, + hasGreeting: make(chan struct{}), + commands: make(map[string]transaction), + done: make(chan struct{}), + } +} + +// Close closes the connection. +func (c *transport) Close() error { + select { + case <-c.done: + return net.ErrClosed + default: + close(c.done) + } + return c.conn.Close() +} + +// ServerConfig returns the server configuration described in a message. +// Will block until the an initial is received, or ctx is canceled. +// +// TODO: move this to Client. +func (c *transport) ServerConfig(ctx context.Context) (Config, error) { + g, err := c.Greeting(ctx) + if err != nil { + return Config{}, err + } + return configFromGreeting(g), nil +} + +// ServerName returns the most recently received server name. +// Will block until an initial is received, or ctx is canceled. +// +// TODO: move this to Client. +func (c *transport) ServerName(ctx context.Context) (string, error) { + g, err := c.Greeting(ctx) + if err != nil { + return "", err + } + return g.ServerName, nil +} + +// ServerTime returns the most recently received timestamp from the server. +// Will block until an initial is received, or ctx is canceled. +// +// TODO: move this to Client. +// TODO: what is used for? +func (c *transport) ServerTime(ctx context.Context) (time.Time, error) { + g, err := c.Greeting(ctx) + if err != nil { + return time.Time{}, err + } + return g.ServerDate.Time, nil +} + +// Command sends an EPP command and returns an EPP response. +// It blocks until a response is received, ctx is canceled, or +// the underlying connection is closed. +func (c *transport) Command(ctx context.Context, cmd *epp.Command) (*epp.Response, error) { + tx, cancel := newTransaction(ctx) + defer cancel() + c.pushCommand(cmd.ClientTransactionID, tx) + + err := c.writeEPP(cmd) + if err != nil { + return nil, err + } + + select { + case <-c.done: + return nil, ErrClosedConnection + case <-ctx.Done(): + return nil, ctx.Err() + case reply := <-tx.reply: + if r, ok := reply.body.(*epp.Response); ok { + return r, reply.err + } + return nil, reply.err + } +} + +// Hello sends an EPP message to the server. +// It will block until the next message is received or ctx is canceled. +func (c *transport) Hello(ctx context.Context) (*epp.Greeting, error) { + tx, cancel := newTransaction(ctx) + defer cancel() + c.pushHello(tx) + + err := c.writeEPP(&epp.Hello{}) + if err != nil { + return nil, err + } + + select { + case <-c.done: + return nil, ErrClosedConnection + case <-ctx.Done(): + return nil, ctx.Err() + case reply := <-tx.reply: + if g, ok := reply.body.(*epp.Greeting); ok { + return g, reply.err + } + return nil, reply.err + } +} + +// Greeting returns the last recieved from the server. +// It blocks until the is received, ctx is canceled, or +// the underlying connection is closed. +func (c *transport) Greeting(ctx context.Context) (*epp.Greeting, error) { + g := c.greeting.Load() + if g != nil { + return g.(*epp.Greeting), nil + } + select { + case <-c.done: + return nil, ErrClosedConnection + case <-ctx.Done(): + return nil, ctx.Err() + case <-c.hasGreeting: + return c.greeting.Load().(*epp.Greeting), nil + } +} + +// writeEPP writes body to the underlying Transport. +// Writes are synchronized, so it is safe to call this from multiple goroutines. +func (c *transport) writeEPP(body epp.Body) error { + x, err := xml.Marshal(epp.EPP{Body: body}) + if err != nil { + return err + } + return c.writeDataUnit(x) +} + +// writeDataUnit writes a single EPP data unit to the underlying Transport. +// Writes are synchronized, so it is safe to call this from multiple goroutines. +func (c *transport) writeDataUnit(p []byte) error { + c.mWrite.Lock() + defer c.mWrite.Unlock() + return c.conn.WriteDataUnit(p) +} + +// readLoop reads EPP messages from c.t and sends them to c.responses. +// It closes c.responses before returning. +// I/O errors are considered fatal and are returned. +func (c *transport) readLoop() { + var err error + defer func() { + c.cleanup(err) + }() + for { + select { + case <-c.done: + return + default: + } + + var p []byte + p, err = c.conn.ReadDataUnit() + if err != nil { + // TODO: log I/O errors. + return + } + + err = c.handleDataUnit(p) + if err != nil { + // TODO: log XML and processing errors. + } + } +} + +func (c *transport) handleDataUnit(p []byte) error { + var e epp.EPP + err := xml.Unmarshal(p, &e) + if err != nil { + // TODO: log XML parsing errors. + // TODO: should XML parsing errors be considered fatal? + return err + } + + // TODO: log processing errors. + return c.handleReply(e.Body) +} + +func (c *transport) handleReply(body epp.Body) error { + switch body := body.(type) { + case *epp.Response: + id := body.TransactionID.Client + if id == "" { + // TODO: log when server responds with an empty client transaction ID. + return TransactionIDError(id) + } + t, ok := c.popCommand(id) + if !ok { + // TODO: log when server responds with unknown transaction ID. + // TODO: keep abandoned transactions around for some period of time. + return TransactionIDError(id) + } + err := c.finalize(t, body, nil) + if err != nil { + return err + } + + case *epp.Greeting: + // Always store the last received from the server. + c.greeting.Store(body) + + // Close hasGreeting this is the first recieved. + select { + case <-c.hasGreeting: + default: + close(c.hasGreeting) + } + + // Pass the to a caller waiting on it. + t, ok := c.popHello() + if ok { + err := c.finalize(t, body, nil) + if err != nil { + return err + } + } + + case *epp.Hello: + // TODO: log if server receives a or . + + case *epp.Command: + // TODO: log if server receives a or . + } + + return nil +} + +func (c *transport) finalize(t transaction, body epp.Body, err error) error { + select { + case <-c.done: + return ErrClosedConnection + case <-t.ctx.Done(): + return t.ctx.Err() + case t.reply <- reply{body: body, err: err}: + } + return nil +} + +// pushHello adds a transaction to the end of the stack. +func (c *transport) pushHello(tx transaction) { + c.mHellos.Lock() + defer c.mHellos.Unlock() + c.hellos = append(c.hellos, tx) +} + +// popHello pops the oldest transaction off the front of the stack. +func (c *transport) popHello() (transaction, bool) { + c.mHellos.Lock() + defer c.mHellos.Unlock() + if len(c.hellos) == 0 { + return transaction{}, false + } + tx := c.hellos[0] + c.hellos = c.hellos[1:] + return tx, true +} + +// pushCommand adds a transaction to the map of in-flight commands. +func (c *transport) pushCommand(id string, tx transaction) error { + c.mCommands.Lock() + defer c.mCommands.Unlock() + _, ok := c.commands[id] + if ok { + return fmt.Errorf("epp: transaction already exists: %s", id) + } + c.commands[id] = tx + return nil +} + +// popCommand removes a transaction from the map of in-flight commands. +func (c *transport) popCommand(id string) (transaction, bool) { + c.mCommands.Lock() + defer c.mCommands.Unlock() + tx, ok := c.commands[id] + if ok { + delete(c.commands, id) + } + return tx, ok +} + +// cleanup cleans up and responds to all in-flight and transactions. +// Each transaction will be finalized with err, which may be nil. +func (c *transport) cleanup(err error) { + c.mHellos.Lock() + hellos := c.hellos + c.hellos = nil + c.mHellos.Unlock() + for _, tx := range hellos { + c.finalize(tx, nil, err) + } + + c.mCommands.Lock() + commands := c.commands + c.commands = nil + c.mCommands.Unlock() + for _, tx := range commands { + c.finalize(tx, nil, err) + } +} diff --git a/xml.go b/xml.go deleted file mode 100644 index 73f0343..0000000 --- a/xml.go +++ /dev/null @@ -1,18 +0,0 @@ -package epp - -import "encoding/xml" - -const ( - // EPP defines the IETF URN for the EPP namespace. - // https://www.iana.org/assignments/xml-registry/ns/epp-1.0.txt - EPP = `urn:ietf:params:xml:ns:epp-1.0` - - // EPPCommon defines the IETF URN for the EPP Common namespace. - // https://www.iana.org/assignments/xml-registry/ns/eppcom-1.0.txt - EPPCommon = `urn:ietf:params:xml:ns:eppcom-1.0` - - startEPP = `` - endEPP = `` - xmlCommandPrefix = xml.Header + startEPP + `` - xmlCommandSuffix = `` + endEPP -) diff --git a/xml_test.go b/xml_test.go deleted file mode 100644 index 66b83e4..0000000 --- a/xml_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package epp - -import ( - "encoding/xml" - "testing" - - "github.com/nbio/st" -) - -func TestMarshalOmitEmpty(t *testing.T) { - v := struct { - XMLName struct{} `xml:"hello"` - Foo string `xml:"foo"` - Bar struct { - Baz string `xml:"baz"` - } `xml:"bar,omitempty"` - }{} - - x, err := xml.Marshal(&v) - st.Expect(t, err, nil) - st.Expect(t, string(x), ``) -}