commit 2297f56b5dce2ae7567b39b561d51bec52b21125 Author: Yoshihiko Abe Date: Tue Jul 2 06:03:35 2024 +0000 Initial public commit diff --git a/README.md b/README.md new file mode 100755 index 0000000..0002546 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# eaclient + +A client for e-amusement (XRPC) services + +# Usage + +This document assumes that you already have a basic understanding of the protocol used by e-amusement services. +Mostly accurate documentation of the protocol can be found elsewhere. + +## Configuration + +In order to use a service, it must first be defined in a config file. +A simple config file containing a single service named `test` can be written like so: +``` +client: + model: "EAM:J:A:A" + srcid: "1000" + +services: + test: + url: "http://test/" + obfuscate: true + compress: "lz77" + encoding: "UTF-8" +``` +See `sample/sample.yml` in this repository for a sample config with more detailed documentation. + +## Requests + +A simple request file looks like this: +``` + + + +``` +The `model` and `srcid` attributes of the `call` node will be automatically filled in using their respective config values. + +## CLI + +``` +Usage: eaclient [OPTIONS] CONFIG SERVICE REQUEST +List of available options: + -m string + Override the client's model + -p string + Override the client's PCBID + -u string + Override the value of the client's User-Agent header +``` +Once you have written your config file, you may use the services defined in it by running `eaclient CONFIG SERVICE REQUEST`. diff --git a/client.go b/client.go new file mode 100755 index 0000000..685dadd --- /dev/null +++ b/client.go @@ -0,0 +1,165 @@ +package eaclient + +import ( + "bytes" + "fmt" + "net/http" + "unicode" + + "github.com/YoshihikoAbe/avsproperty" +) + +type clientError string + +func (err clientError) Error() string { + return "eaclient: " + string(err) +} + +type CompressType int + +const ( + CompressDisable CompressType = iota + CompressNone + CompressLZ +) + +func (c *CompressType) UnmarshalText(b []byte) error { + switch s := string(bytes.ToLower(b)); s { + case "": + fallthrough + case "disable": + *c = CompressDisable + + case "none": + *c = CompressNone + + case "lz": + fallthrough + case "lz77": + *c = CompressLZ + + default: + return clientError("invalid compress type: " + s) + } + return nil +} + +type FormatType int + +const ( + FormatBinary FormatType = iota + FormatXML +) + +func (f *FormatType) UnmarshalText(b []byte) error { + switch s := string(bytes.ToLower(b)); s { + case "": + fallthrough + case "binary": + *f = FormatBinary + + case "xml": + *f = FormatXML + + default: + return clientError("invalid format type: " + s) + } + return nil +} + +type Service struct { + URL string `yaml:"url"` + Host string `yaml:"host"` + Obfuscate bool `yaml:"obfuscate"` + Compress CompressType `yaml:"compress"` + Format FormatType `yaml:"format"` + Encoding string `yaml:"encoding"` +} + +type Client struct { + Model string `yaml:"model"` + Srcid string `yaml:"srcid"` + UserAgent string `yaml:"useragent"` + DisableQuery bool `yaml:"disable_query"` + HTTP http.Client `yaml:"-"` +} + +func (client *Client) Send(svc Service, call *avsproperty.Node) (*avsproperty.Property, error) { + if !validModel(client.Model) { + return nil, clientError("invalid character in model") + } + + if call == nil { + return nil, clientError("call node is nil") + } + if call.Name().String() != "call" { + return nil, clientError("root node's name is not \"call\"") + } + if len(call.Children()) != 1 { + return nil, clientError("call node has an invalid number of children") + } + + module := call.Children()[0] + method := module.AttributeValue("method") + if method == "" { + return nil, clientError("module node does not contain a method attribute") + } + + if !client.DisableQuery { + // this isn't used for routing on real e-amusement, + // but it makes the logs look more authentic + svc.URL += fmt.Sprintf("?model=%s&f=%s.%s", client.Model, module.Name(), method) + } + + call.SetAttribute("srcid", client.Srcid) + call.SetAttribute("model", client.Model) + + prop := &avsproperty.Property{ + Root: call, + } + err := client.do(prop, svc) + if err != nil { + return nil, err + } + return prop, nil +} + +func (client *Client) do(prop *avsproperty.Property, svc Service) error { + req, err := EncodeRequest(prop, svc) + if err != nil { + return err + } + if s := client.UserAgent; s != "" { + req.Header.Set("User-Agent", s) + } else { + req.Header.Set("User-Agent", "EAMUSE.XRPC/1.0") + } + + resp, err := client.HTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if err := DecodeResponse(prop, resp); err != nil { + return err + } + + if prop.Root == nil { + return clientError("empty response property") + } + if prop.Root.Name().String() != "response" { + return clientError("name of root node in response property is not \"response\"") + } + + return nil +} + +func validModel(s string) bool { + for _, r := range s { + if !unicode.In(r, unicode.Number, unicode.Letter) && r != ':' { + return false + } + } + return true +} diff --git a/cmd/eaclient/main.go b/cmd/eaclient/main.go new file mode 100755 index 0000000..9c38d51 --- /dev/null +++ b/cmd/eaclient/main.go @@ -0,0 +1,90 @@ +package main + +import ( + "flag" + "fmt" + "net/http" + "os" + + "github.com/YoshihikoAbe/avsproperty" + "github.com/YoshihikoAbe/eaclient" + "gopkg.in/yaml.v3" +) + +var ( + userAgent, srcid, model string + + config struct { + Client eaclient.Client `yaml:"client"` + Services map[string]eaclient.Service `yaml:"services"` + } +) + +func main() { + flag.StringVar(&userAgent, "u", "", "Override the value of the client's User-Agent header") + flag.StringVar(&srcid, "p", "", "Override the client's srcid") + flag.StringVar(&model, "m", "", "Override the client's model") + + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: %s [OPTIONS] CONFIG SERVICE REQUEST\nList of available options:\n", os.Args[0]) + flag.PrintDefaults() + } + flag.Parse() + if len(flag.Args()) < 3 { + flag.Usage() + os.Exit(1) + } + + if err := loadConfig(); err != nil { + fatal("failed to load config:", err) + } + transport := http.DefaultTransport.(*http.Transport) + transport.DisableCompression = true + transport.ForceAttemptHTTP2 = false + + svcName := flag.Arg(1) + svc, ok := config.Services[flag.Arg(1)] + if !ok { + fatal("service not found:", svcName) + } + + prop := &avsproperty.Property{} + if err := prop.ReadFile(flag.Arg(2)); err != nil { + fatal("failed to read property:", err) + } + resp, err := config.Client.Send(svc, prop.Root) + if err != nil { + fatal(err) + } + + resp.Settings.Format = avsproperty.FormatPrettyXML + if err := resp.Write(os.Stdout); err != nil { + fatal(err) + } +} + +func loadConfig() error { + b, err := os.ReadFile(flag.Arg(0)) + if err != nil { + return err + } + if err := yaml.Unmarshal(b, &config); err != nil { + return err + } + + if userAgent != "" { + config.Client.UserAgent = userAgent + } + if srcid != "" { + config.Client.Srcid = srcid + } + if model != "" { + config.Client.Model = model + } + return nil +} + +func fatal(v ...any) { + fmt.Fprintln(os.Stderr, v...) + os.Exit(1) +} diff --git a/go.mod b/go.mod new file mode 100755 index 0000000..d52e351 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/YoshihikoAbe/eaclient + +go 1.22.3 + +require ( + github.com/YoshihikoAbe/avslz v0.0.1 + github.com/YoshihikoAbe/avsproperty v0.0.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require golang.org/x/text v0.16.0 // indirect diff --git a/go.sum b/go.sum new file mode 100755 index 0000000..167ff39 --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/YoshihikoAbe/avslz v0.0.1 h1:62GYESTsRJtF28+NhNAeS4bu0GJvY2iZvctQyrS+EpA= +github.com/YoshihikoAbe/avslz v0.0.1/go.mod h1:ih2t09YcyB+bSQ3awxyG+fF/fwaBh/zlLzpUuwsU8x4= +github.com/YoshihikoAbe/avsproperty v0.0.1 h1:EhaxmJoVHY0iXmOOSYC2g8PdlMJ60UOF9DpTqE4g9xo= +github.com/YoshihikoAbe/avsproperty v0.0.1/go.mod h1:QnubObUKj734sRrFtaFzp9Zrk3LKLJHp3+iqIMhqdXM= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/obfuscate.go b/obfuscate.go new file mode 100755 index 0000000..a680529 --- /dev/null +++ b/obfuscate.go @@ -0,0 +1,46 @@ +package eaclient + +import ( + "crypto/cipher" + "crypto/md5" + "crypto/rand" + "crypto/rc4" + "encoding/binary" + "encoding/hex" + "strings" + "time" +) + +type eamuseInfo [6]byte + +func (info eamuseInfo) String() string { + return "1-" + hex.EncodeToString(info[:4]) + "-" + hex.EncodeToString(info[4:]) +} + +func (info *eamuseInfo) fill() { + binary.BigEndian.PutUint32(info[:], uint32(time.Now().Unix())) + rand.Read(info[4:]) +} + +func (info *eamuseInfo) parse(s string) error { + split := strings.Split(s, "-") + if len(split) != 3 || split[0] != "1" { + return clientError("malformed X-Eamuse-Info value") + } + _, err := hex.Decode(info[:], []byte(split[1]+split[2])) + return err +} + +func (info eamuseInfo) makeCipher() cipher.Stream { + secret := []byte{ + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x69, 0xD7, + 0x46, 0x27, 0xD9, 0x85, 0xEE, 0x21, 0x87, 0x16, + 0x15, 0x70, 0xD0, 0x8D, 0x93, 0xB1, 0x24, 0x55, + 0x03, 0x5B, 0x6D, 0xF0, 0xD8, 0x20, 0x5D, 0xF5, + } + copy(secret, info[:]) + + key := md5.Sum(secret) + cipher, _ := rc4.NewCipher(key[:]) + return cipher +} diff --git a/protocol.go b/protocol.go new file mode 100755 index 0000000..de45e97 --- /dev/null +++ b/protocol.go @@ -0,0 +1,97 @@ +package eaclient + +import ( + "bytes" + "crypto/cipher" + "io" + "net/http" + + "github.com/YoshihikoAbe/avslz" + "github.com/YoshihikoAbe/avsproperty" +) + +const ( + infoHeader = "X-Eamuse-Info" + compressHeader = "X-Compress" +) + +func EncodeRequest(prop *avsproperty.Property, svc Service) (*http.Request, error) { + body := bytes.NewBuffer(nil) + wr := io.Writer(body) + + request, err := http.NewRequest("POST", svc.URL, nil) + if err != nil { + return nil, err + } + request.Host = svc.Host + + if svc.Obfuscate { + info := eamuseInfo{} + info.fill() + wr = cipher.StreamWriter{ + W: wr, + S: info.makeCipher(), + } + request.Header.Set(infoHeader, info.String()) + } + + var lz *avslz.Writer + if svc.Compress == CompressLZ { + request.Header.Set(compressHeader, "lz77") + lz = avslz.NewWriter(wr) + wr = lz + } else if svc.Compress == CompressNone { + request.Header.Set(compressHeader, "none") + } + + if svc.Format == FormatBinary { + prop.Settings.Format = avsproperty.FormatBinary + } else { + prop.Settings.Format = avsproperty.FormatXML + } + encoding := avsproperty.EncodingByName(svc.Encoding) + if encoding == nil { + return nil, clientError("invalid encoding: " + svc.Encoding) + } + prop.Settings.Encoding = encoding + if err := prop.Write(wr); err != nil { + return nil, err + } + + if lz != nil { + if err := lz.Close(); err != nil { + return nil, err + } + } + + request.Body = io.NopCloser(body) + request.ContentLength = int64(body.Len()) + + return request, nil +} + +func DecodeResponse(prop *avsproperty.Property, resp *http.Response) error { + if resp.StatusCode != 200 { + return clientError("invalid HTTP status: " + resp.Status) + } + rd := io.Reader(resp.Body) + + if s := resp.Header.Get(infoHeader); s != "" { + info := eamuseInfo{} + if err := info.parse(s); err != nil { + return err + } + rd = cipher.StreamReader{ + R: rd, + S: info.makeCipher(), + } + } + + if s := resp.Header.Get(compressHeader); s == "lz77" { + rd = avslz.NewReader(rd) + } else if s != "" && s != "none" { + return clientError("invalid compress type in response: " + s) + } + + return prop.Read(rd) +} diff --git a/sample/facility.get b/sample/facility.get new file mode 100755 index 0000000..747545b --- /dev/null +++ b/sample/facility.get @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/sample/package.list b/sample/package.list new file mode 100755 index 0000000..359df05 --- /dev/null +++ b/sample/package.list @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/sample/sample.yml b/sample/sample.yml new file mode 100755 index 0000000..56e6f27 --- /dev/null +++ b/sample/sample.yml @@ -0,0 +1,38 @@ +# Client configuration +client: + # Specifies the value of the model attribute + model: "EAM:J:A:A" + # Specifies the value of the srcid attribute + srcid: "1000" + # Specifies the value of the User-Agent header. If this field is blank, a default value will be used instead + useragent: "EAMUSE.Test/1.0" + # Tell the client to not append the query string (?model=EAM:J:A:A&f=MODULE.METHOD) to the end of service URLS + disable_query: false + +# Service configurations +services: + # Name of the service + services: + # Specfies the URL of the server + url: "http://services/" + # Optionally overrides the Host header + host: "eamuse.konami.fun" + # Enable obfuscation. Most services require this be enabled + obfuscate: true + # Supported values: "lz77", "none", "disable". By default, this is set to "disable" + compress: "lz77" + # Supported values: "binary", "xml". By default, this is set to "binary" + format: "binary" + # Supports standard AVS2 property encodings. This is generally set to "UTF-8" or "SHIFT-JIS" + encoding: "UTF-8" + + package: + url: "http://package/package/service" + compress: "lz77" + encoding: "SHIFT-JIS" + + facility: + url: "http://facility/facility/service" + obfuscate: true + compress: "lz77" + encoding: "SHIFT-JIS" \ No newline at end of file diff --git a/sample/services.get b/sample/services.get new file mode 100755 index 0000000..b69afe0 --- /dev/null +++ b/sample/services.get @@ -0,0 +1,3 @@ + + + \ No newline at end of file