Initial public commit
This commit is contained in:
@@ -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:
|
||||
```
|
||||
<call>
|
||||
<MODULE method="METHOD"/>
|
||||
</call>
|
||||
```
|
||||
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`.
|
||||
@@ -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
|
||||
}
|
||||
Executable
+90
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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=
|
||||
Executable
+46
@@ -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
|
||||
}
|
||||
Executable
+97
@@ -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)
|
||||
}
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
<call>
|
||||
<facility method="get"/>
|
||||
</call>
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
<call>
|
||||
<package method="list" pkgtype="all"/>
|
||||
</call>
|
||||
Executable
+38
@@ -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"
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
<call>
|
||||
<services method="get"/>
|
||||
</call>
|
||||
Reference in New Issue
Block a user