4c9aaeb769
resolves #2393 loads, edits, and re-writes the file instead of inline edits that seem to break. This inefficient but it works in my testing.
508 lines
13 KiB
Go
508 lines
13 KiB
Go
package parser
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"github.com/apex/log"
|
|
"github.com/beevik/etree"
|
|
"github.com/buger/jsonparser"
|
|
"github.com/icza/dyno"
|
|
"github.com/magiconair/properties"
|
|
"github.com/pkg/errors"
|
|
"github.com/pterodactyl/wings/config"
|
|
"gopkg.in/ini.v1"
|
|
"gopkg.in/yaml.v2"
|
|
"io/ioutil"
|
|
"os"
|
|
"path/filepath"
|
|
"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"
|
|
)
|
|
|
|
type ConfigurationParser string
|
|
|
|
func (cp ConfigurationParser) String() string {
|
|
return string(cp)
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Custom unmarshaler for configuration files. If there is an error while parsing out the
|
|
// replacements, don't fail the entire operation, just log a global warning so someone can
|
|
// find the issue, and return an empty array of replacements.
|
|
//
|
|
// I imagine people will notice configuration replacement isn't working correctly and then
|
|
// the logs should help better expose that issue.
|
|
func (f *ConfigurationFile) UnmarshalJSON(data []byte) error {
|
|
var m map[string]*json.RawMessage
|
|
if err := json.Unmarshal(data, &m); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := json.Unmarshal(*m["file"], &f.FileName); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := json.Unmarshal(*m["parser"], &f.Parser); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := json.Unmarshal(*m["replace"], &f.Replace); err != nil {
|
|
log.WithField("file", f.FileName).WithField("error", err).Warn("failed to unmarshal configuration file replacement")
|
|
|
|
f.Replace = []ConfigurationFileReplacement{}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Regex to match paths such as foo[1].bar[2] and convert them into a format that
|
|
// gabs can work with, such as foo.1.bar.2 in this case. This is applied when creating
|
|
// the struct for the configuration file replacements.
|
|
var cfrMatchReplacement = regexp.MustCompile(`\[(\d+)]`)
|
|
|
|
// Defines a single find/replace instance for a given server configuration file.
|
|
type ConfigurationFileReplacement struct {
|
|
Match string `json:"match"`
|
|
IfValue string `json:"if_value"`
|
|
ReplaceWith ReplaceValue `json:"replace_with"`
|
|
}
|
|
|
|
// Handles unmarshaling the JSON representation into a struct that provides more useful
|
|
// data to this functionality.
|
|
func (cfr *ConfigurationFileReplacement) UnmarshalJSON(data []byte) error {
|
|
m, err := jsonparser.GetString(data, "match")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cfr.Match = m
|
|
|
|
iv, err := jsonparser.GetString(data, "if_value")
|
|
// We only check keypath here since match & replace_with should be present on all of
|
|
// them, however if_value is optional.
|
|
if err != nil && err != jsonparser.KeyPathNotFoundError {
|
|
return err
|
|
}
|
|
cfr.IfValue = iv
|
|
|
|
rw, dt, _, err := jsonparser.Get(data, "replace_with")
|
|
if err != nil {
|
|
if err != jsonparser.KeyPathNotFoundError {
|
|
return err
|
|
}
|
|
|
|
// Okay, likely dealing with someone who forgot to upgrade their eggs, so in
|
|
// that case, fallback to using the old key which was "value".
|
|
rw, dt, _, err = jsonparser.Get(data, "value")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
cfr.ReplaceWith = ReplaceValue{
|
|
value: rw,
|
|
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, internal bool) error {
|
|
log.WithField("path", path).WithField("parser", f.Parser.String()).Debug("parsing server configuration file")
|
|
|
|
if mb, err := json.Marshal(config.Get()); err != nil {
|
|
return err
|
|
} else {
|
|
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
|
|
case Ini:
|
|
err = f.parseIniFile(path)
|
|
break
|
|
case Xml:
|
|
err = f.parseXmlFile(path)
|
|
break
|
|
}
|
|
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
// File doesn't exist, we tried creating it, and same error is returned? Pretty
|
|
// sure this pathway is impossible, but if not, abort here.
|
|
if internal {
|
|
return nil
|
|
}
|
|
|
|
b := strings.TrimSuffix(path, filepath.Base(path))
|
|
if err := os.MkdirAll(b, 0755); err != nil {
|
|
return errors.Wrap(err, "failed to create base directory for missing configuration file")
|
|
} else {
|
|
if _, err := os.Create(path); err != nil {
|
|
return errors.Wrap(err, "failed to create missing configuration file")
|
|
}
|
|
}
|
|
|
|
return f.Parse(path, true)
|
|
}
|
|
|
|
return errors.WithStack(err)
|
|
}
|
|
|
|
// Parses an xml file.
|
|
func (f *ConfigurationFile) parseXmlFile(path string) error {
|
|
doc := etree.NewDocument()
|
|
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
|
|
if _, err := doc.ReadFrom(file); err != nil {
|
|
return err
|
|
}
|
|
|
|
// If there is no root we should create a basic start to the file. This isn't required though,
|
|
// and if it doesn't work correctly I'll just remove the code.
|
|
if doc.Root() == nil {
|
|
doc.CreateProcInst("xml", `version="1.0" encoding="utf-8"`)
|
|
}
|
|
|
|
for i, replacement := range f.Replace {
|
|
value, err := f.LookupConfigurationValue(replacement)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// If this is the first item and there is no root element, create that root now and apply
|
|
// it for future use.
|
|
if i == 0 && doc.Root() == nil {
|
|
parts := strings.SplitN(replacement.Match, ".", 2)
|
|
doc.SetRoot(doc.CreateElement(parts[0]))
|
|
}
|
|
|
|
path := "./" + strings.Replace(replacement.Match, ".", "/", -1)
|
|
|
|
// If we're not doing a wildcard replacement go ahead and create the
|
|
// missing element if we cannot find it yet.
|
|
if !strings.Contains(path, "*") {
|
|
parts := strings.Split(replacement.Match, ".")
|
|
|
|
// Set the initial element to be the root element, and then work from there.
|
|
var element = doc.Root()
|
|
|
|
// Iterate over the path to create the required structure for the given element's path.
|
|
// This does not set a value, only ensures that the base structure exists. We start at index
|
|
// 1 because an XML document can only contain a single root element, and from there we'll
|
|
// work our way down the chain.
|
|
for _, tag := range parts[1:] {
|
|
if e := element.FindElement(tag); e == nil {
|
|
element = element.CreateElement(tag)
|
|
} else {
|
|
element = e
|
|
}
|
|
}
|
|
}
|
|
|
|
// Iterate over the elements we found and update their values.
|
|
for _, element := range doc.FindElements(path) {
|
|
if xmlValueMatchRegex.MatchString(value) {
|
|
k := xmlValueMatchRegex.ReplaceAllString(value, "$1")
|
|
v := xmlValueMatchRegex.ReplaceAllString(value, "$2")
|
|
|
|
element.CreateAttr(k, v)
|
|
} else {
|
|
element.SetText(value)
|
|
}
|
|
}
|
|
}
|
|
|
|
// If you don't truncate the file you'll end up duplicating the data in there (or just appending
|
|
// to the end of the file. We don't want to do that.
|
|
if err := file.Truncate(0); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Move the cursor to the start of the file to avoid weird spacing issues.
|
|
file.Seek(0, 0)
|
|
|
|
// Ensure the XML is indented properly.
|
|
doc.Indent(2)
|
|
|
|
// Truncate the file before attempting to write the changes.
|
|
if err := os.Truncate(path, 0); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Write the XML to the file.
|
|
_, err = doc.WriteTo(file)
|
|
|
|
return err
|
|
}
|
|
|
|
// Parses an ini file.
|
|
func (f *ConfigurationFile) parseIniFile(path string) error {
|
|
// Ini package can't handle a non-existent file, so handle that automatically here
|
|
// by creating it if not exists. Then, immediately close the file since we will use
|
|
// other methods to write the new contents.
|
|
file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
file.Close()
|
|
|
|
cfg, err := ini.Load(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, replacement := range f.Replace {
|
|
path := strings.SplitN(replacement.Match, ".", 2)
|
|
|
|
value, err := f.LookupConfigurationValue(replacement)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
k := path[0]
|
|
s := cfg.Section("")
|
|
// Passing a key of foo.bar will look for "bar" in the "[foo]" section of the file.
|
|
if len(path) == 2 {
|
|
k = path[1]
|
|
s = cfg.Section(path[0])
|
|
}
|
|
|
|
// If no section was found, create that new section now and then set the
|
|
// section value we're using to be the new one.
|
|
if s == nil {
|
|
s, err = cfg.NewSection(path[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// If the key exists in the file go ahead and set the value, otherwise try to
|
|
// create it in the section.
|
|
if s.HasKey(k) {
|
|
s.Key(k).SetValue(value)
|
|
} else {
|
|
if _, err := s.NewKey(k, value); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
return cfg.SaveTo(path)
|
|
}
|
|
|
|
// Parses 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 err
|
|
}
|
|
|
|
data, err := f.IterateOverJson(b)
|
|
if err != nil {
|
|
return 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)
|
|
}
|
|
|
|
i := make(map[string]interface{})
|
|
if err := yaml.Unmarshal(b, &i); 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 significantly easier.
|
|
jsonBytes, err := json.Marshal(dyno.ConvertMapI2MapS(i))
|
|
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.Marshal(data.Data())
|
|
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 {
|
|
// read file
|
|
input, err := ioutil.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// split file into lines to parse one by one.
|
|
lines := strings.Split(string(input), "\n")
|
|
|
|
for i, line := range lines {
|
|
for _, replace := range f.Replace {
|
|
// skip if line doesn't have the prefix
|
|
if !strings.HasPrefix(line, replace.Match) {
|
|
continue
|
|
}
|
|
|
|
// replace line if it has the prefix
|
|
lines[i] = replace.ReplaceWith.String()
|
|
}
|
|
}
|
|
|
|
// join all the file lines with new lines between
|
|
output := strings.Join(lines, "\n")
|
|
err = ioutil.WriteFile(path, []byte(output), 0644)
|
|
if err != nil {
|
|
return 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 {
|
|
// Open the file.
|
|
f2, err := os.Open(path)
|
|
if err != nil {
|
|
return errors.WithStack(err)
|
|
}
|
|
|
|
var s strings.Builder
|
|
|
|
// Get any header comments from the file.
|
|
scanner := bufio.NewScanner(f2)
|
|
for scanner.Scan() {
|
|
text := scanner.Text()
|
|
if len(text) > 0 && text[0] != '#' {
|
|
break
|
|
}
|
|
|
|
s.WriteString(text)
|
|
s.WriteString("\n")
|
|
}
|
|
|
|
// Close the file.
|
|
_ = f2.Close()
|
|
|
|
// Handle any scanner errors.
|
|
if err := scanner.Err(); err != nil {
|
|
return errors.WithStack(err)
|
|
}
|
|
|
|
// Decode the properties file.
|
|
p, err := properties.LoadFile(path, properties.UTF8)
|
|
if err != nil {
|
|
return errors.WithStack(err)
|
|
}
|
|
|
|
// Replace any values that need to be replaced.
|
|
for _, replace := range f.Replace {
|
|
data, err := f.LookupConfigurationValue(replace)
|
|
if err != nil {
|
|
return errors.WithStack(err)
|
|
}
|
|
|
|
v, ok := p.Get(replace.Match)
|
|
// Don't attempt to replace the value if we're looking for a specific value and
|
|
// it does not match. If there was no match at all in the file for this key but
|
|
// we're doing an IfValue match, do nothing.
|
|
if replace.IfValue != "" && (!ok || (ok && v != replace.IfValue)) {
|
|
continue
|
|
}
|
|
|
|
if _, _, err := p.Set(replace.Match, data); err != nil {
|
|
return errors.WithStack(err)
|
|
}
|
|
}
|
|
|
|
// Add the new file content to the string builder.
|
|
for _, key := range p.Keys() {
|
|
value, ok := p.Get(key)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
s.WriteString(key)
|
|
s.WriteByte('=')
|
|
s.WriteString(strings.Trim(strconv.QuoteToASCII(value), `"`))
|
|
s.WriteString("\n")
|
|
}
|
|
|
|
// Open the file for writing.
|
|
w, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
|
|
if err != nil {
|
|
return errors.WithStack(err)
|
|
}
|
|
defer w.Close()
|
|
|
|
// Write the data to the file.
|
|
if _, err := w.Write([]byte(s.String())); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|