Initial public commit

This commit is contained in:
Yoshihiko Abe
2024-07-02 06:03:35 +00:00
commit 2297f56b5d
11 changed files with 514 additions and 0 deletions
Executable
+50
View File
@@ -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`.
Executable
+165
View File
@@ -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
}
+90
View File
@@ -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)
}
Executable
+11
View File
@@ -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
Executable
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+3
View File
@@ -0,0 +1,3 @@
<call>
<facility method="get"/>
</call>
+3
View File
@@ -0,0 +1,3 @@
<call>
<package method="list" pkgtype="all"/>
</call>
+38
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
<call>
<services method="get"/>
</call>