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 { file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644) if err != nil { return 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.ReplaceWith.String(), 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+"\n"), int64(len(scanner.Bytes()))); err != nil { return err } } } if err := scanner.Err(); 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 }