wings/parser/parser.go

351 lines
10 KiB
Go
Raw Normal View History

package parser
import (
"bufio"
"encoding/json"
"fmt"
"github.com/Jeffail/gabs/v2"
"github.com/buger/jsonparser"
"github.com/ghodss/yaml"
"github.com/iancoleman/strcase"
"github.com/magiconair/properties"
"github.com/pkg/errors"
"github.com/pterodactyl/wings/config"
"go.uber.org/zap"
"io/ioutil"
"os"
"regexp"
"strconv"
"strings"
)
// The file parsing options that are available for a server configuration file.
const (
File = "file"
Yaml = "yaml"
Properties = "properties"
Ini = "ini"
Json = "json"
Xml = "xml"
)
// Regex to match anything that has a value matching the format of {{ config.$1 }} which
// will cause the program to lookup that configuration value from itself and set that
// value to the configuration one.
//
// This allows configurations to reference values that are node dependent, such as the
// internal IP address used by the daemon, useful in Bungeecord setups for example, where
// it is common to see variables such as "{{config.docker.interface}}"
var configMatchRegex = regexp.MustCompile(`^{{\s?config\.([\w.-]+)\s?}}$`)
type ConfigurationParser string
// Defines a configuration file for the server startup. These will be looped over
// and modified before the server finishes booting.
type ConfigurationFile struct {
FileName string `json:"file"`
Parser ConfigurationParser `json:"parser"`
Replace []ConfigurationFileReplacement `json:"replace"`
// Tracks Wings' configuration so that we can quickly get values
// out of it when variables request it.
configuration []byte
}
// Defines a single find/replace instance for a given server configuration file.
type ConfigurationFileReplacement struct {
Match string `json:"match"`
Value string `json:"value"`
ValueType jsonparser.ValueType `json:"-"`
}
func (cfr *ConfigurationFileReplacement) UnmarshalJSON(data []byte) error {
if m, err := jsonparser.GetString(data, "match"); err != nil {
return err
} else {
cfr.Match = m
}
if v, dt, _, err := jsonparser.Get(data, "value"); err != nil {
return err
} else {
if dt != jsonparser.String && dt != jsonparser.Number && dt != jsonparser.Boolean {
return errors.New(
fmt.Sprintf("cannot parse JSON: received unexpected replacement value type: %d", dt),
)
}
cfr.Value = string(v)
cfr.ValueType = dt
}
return nil
}
// Parses a given configuration file and updates all of the values within as defined
// in the API response from the Panel.
func (f *ConfigurationFile) Parse(path string) error {
zap.S().Debugw("parsing configuration file", zap.String("path", path), zap.String("parser", string(f.Parser)))
mb, _ := json.Marshal(config.Get())
f.configuration = mb
var err error
switch f.Parser {
case Properties:
err = f.parsePropertiesFile(path)
break
case File:
err = f.parseTextFile(path)
break
case Yaml, "yml":
err = f.parseYamlFile(path)
break
case Json:
err = f.parseJsonFile(path)
break
}
return err
}
// Gets the []byte representation of a configuration file to be passed through to other
// handler functions. If the file does not currently exist, it will be created.
func readFileBytes(path string) ([]byte, error) {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644)
if err != nil {
return nil, err
}
defer file.Close()
return ioutil.ReadAll(file)
}
// Helper function to set the value of the JSON key item based on the jsonparser value
// type returned.
func setPathway(c *gabs.Container, path string, value []byte, dt jsonparser.ValueType) error {
var err error
switch dt {
case jsonparser.Number:
{
v, _ := strconv.Atoi(string(value))
_, err = c.SetP(v, path)
}
break
case jsonparser.Boolean:
{
v, _ := strconv.ParseBool(string(value))
_, err = c.SetP(v, path)
}
break
default:
_, err = c.SetP(string(value), path)
}
return err
}
// Iterate over an unstructured JSON/YAML/etc. interface and set all of the required
// key/value pairs for the configuration file.
//
// We need to support wildcard characters in key searches, this allows you to make
// modifications to multiple keys at once, especially useful for games with multiple
// configurations per-world (such as Spigot and Bungeecord) where we'll need to make
// adjustments to the bind address for the user.
//
// This does not currently support nested matches. container.*.foo.*.bar will not work.
func (f *ConfigurationFile) IterateOverJson(data []byte) (*gabs.Container, error) {
parsed, err := gabs.ParseJSON(data)
if err != nil {
return nil, err
}
for _, v := range f.Replace {
value, dt, err := f.lookupConfigurationValue(v)
if err != nil {
return nil, err
}
// Check for a wildcard character, and if found split the key on that value to
// begin doing a search and replace in the data.
if strings.Contains(v.Match, ".*") {
parts := strings.SplitN(v.Match, ".*", 2)
// Iterate over each matched child and set the remaining path to the value
// that is passed through in the loop.
//
// If the child is a null value, nothing will happen. Seems reasonable as of the
// time this code is being written.
for _, child := range parsed.Path(strings.Trim(parts[0], ".")).Children() {
if err := setPathway(child, strings.Trim(parts[1], "."), value, dt); err != nil {
return nil, err
}
}
} else {
if err = setPathway(parsed, v.Match, value, dt); err != nil {
return nil, err
}
}
}
return parsed, nil
}
// Prases a json file updating any matching key/value pairs. If a match is not found, the
// value is set regardless in the file. See the commentary in parseYamlFile for more details
// about what is happening during this process.
func (f *ConfigurationFile) parseJsonFile(path string) error {
b, err := readFileBytes(path)
if err != nil {
return errors.WithStack(err)
}
data, err := f.IterateOverJson(b)
if err != nil {
return errors.WithStack(err)
}
output := []byte(data.StringIndent("", " "))
return ioutil.WriteFile(path, output, 0644)
}
// Parses a yaml file and updates any matching key/value pairs before persisting
// it back to the disk.
func (f *ConfigurationFile) parseYamlFile(path string) error {
b, err := readFileBytes(path)
if err != nil {
return errors.WithStack(err)
}
// Unmarshal the yaml data into a JSON interface such that we can work with
// any arbitrary data structure. If we don't do this, I can't use gabs which
// makes working with unknown JSON signficiantly easier.
jsonBytes, err := yaml.YAMLToJSON(b)
if err != nil {
return errors.WithStack(err)
}
// Now that the data is converted, treat it just like JSON and pass it to the
// iterator function to update values as necessary.
data, err := f.IterateOverJson(jsonBytes)
if err != nil {
return errors.WithStack(err)
}
// Remarshal the JSON into YAML format before saving it back to the disk.
marshaled, err := yaml.JSONToYAML(data.Bytes())
if err != nil {
return errors.WithStack(err)
}
return ioutil.WriteFile(path, marshaled, 0644)
}
// Parses a text file using basic find and replace. This is a highly inefficient method of
// scanning a file and performing a replacement. You should attempt to use anything other
// than this function where possible.
func (f *ConfigurationFile) parseTextFile(path string) error {
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644)
if err != nil {
return errors.WithStack(err)
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
hasReplaced := false
t := scanner.Text()
// Iterate over the potential replacements for the line and check if there are
// any matches.
for _, replace := range f.Replace {
if !strings.HasPrefix(t, replace.Match) {
continue
}
hasReplaced = true
t = strings.Replace(t, replace.Match, replace.Value, 1)
}
// If there was a replacement that occurred on this specific line, do a write to the file
// immediately to write that modified content to the disk.
if hasReplaced {
if _, err := file.WriteAt([]byte(t), int64(len(scanner.Bytes()))); err != nil {
return errors.WithStack(err)
}
}
}
if err := scanner.Err(); err != nil {
return errors.WithStack(err)
}
return nil
}
// Parses a properties file and updates the values within it to match those that
// are passed. Writes the file once completed.
func (f *ConfigurationFile) parsePropertiesFile(path string) error {
p, err := properties.LoadFile(path, properties.UTF8)
if err != nil {
return errors.WithStack(err)
}
for _, replace := range f.Replace {
data, _, err := f.lookupConfigurationValue(replace)
if err != nil {
return errors.WithStack(err)
}
if _, _, err := p.Set(replace.Match, string(data)); err != nil {
return errors.WithStack(err)
}
}
w, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return errors.WithStack(err)
}
_, err = p.Write(w, properties.UTF8)
return err
}
// Looks up a configuration value on the Daemon given a dot-notated syntax.
func (f *ConfigurationFile) lookupConfigurationValue(cfr ConfigurationFileReplacement) ([]byte, jsonparser.ValueType, error) {
if !configMatchRegex.Match([]byte(cfr.Value)) {
return []byte(cfr.Value), cfr.ValueType, nil
}
// If there is a match, lookup the value in the configuration for the Daemon. If no key
// is found, just return the string representation, otherwise use the value from the
// daemon configuration here.
huntPath := configMatchRegex.ReplaceAllString(cfr.Value, "$1")
var path []string
// The camel casing is important here, the configuration for the Daemon does not use
// JSON, and as such all of the keys will be generated in CamelCase format, rather than
// the expected snake_case from the old Daemon.
for _, value := range strings.Split(huntPath, ".") {
path = append(path, strcase.ToCamel(value))
}
// Look for the key in the configuration file, and if found return that value to the
// calling function.
match, dt, _, err := jsonparser.Get(f.configuration, path...)
if err != nil {
if err != jsonparser.KeyPathNotFoundError {
return match, dt, errors.WithStack(err)
}
// If there is no key, keep the original value intact, that way it is obvious there
// is a replace issue at play.
return []byte(cfr.Value), cfr.ValueType, nil
} else {
return match, cfr.ValueType, nil
}
}