package config import ( "context" "crypto/tls" "fmt" "io/ioutil" "os" "os/exec" "os/user" "path" "path/filepath" "regexp" "strings" "sync" "text/template" "time" "emperror.dev/errors" "github.com/apex/log" "github.com/cobaugh/osrelease" "github.com/creasty/defaults" "github.com/gbrlsnchs/jwt/v3" "github.com/pterodactyl/wings/system" "gopkg.in/yaml.v2" ) const DefaultLocation = "/etc/pterodactyl/config.yml" // DefaultTLSConfig sets sane defaults to use when configuring the internal // webserver to listen for public connections. // // @see https://blog.cloudflare.com/exposing-go-on-the-internet var DefaultTLSConfig = &tls.Config{ NextProtos: []string{"h2", "http/1.1"}, CipherSuites: []uint16{ tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256, tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384, tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256, tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256, }, PreferServerCipherSuites: true, MinVersion: tls.VersionTLS12, MaxVersion: tls.VersionTLS13, CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256}, } var mu sync.RWMutex var _config *Configuration var _jwtAlgo *jwt.HMACSHA var _debugViaFlag bool // Locker specific to writing the configuration to the disk, this happens // in areas that might already be locked so we don't want to crash the process. var _writeLock sync.Mutex // SftpConfiguration defines the configuration of the internal SFTP server. type SftpConfiguration struct { // The bind address of the SFTP server. Address string `default:"0.0.0.0" json:"bind_address" yaml:"bind_address"` // The bind port of the SFTP server. Port int `default:"2022" json:"bind_port" yaml:"bind_port"` // If set to true, no write actions will be allowed on the SFTP server. ReadOnly bool `default:"false" yaml:"read_only"` } // ApiConfiguration defines the configuration for the internal API that is // exposed by the Wings webserver. type ApiConfiguration struct { // The interface that the internal webserver should bind to. Host string `default:"0.0.0.0" yaml:"host"` // The port that the internal webserver should bind to. Port int `default:"8080" yaml:"port"` // SSL configuration for the daemon. Ssl struct { Enabled bool `json:"enabled" yaml:"enabled"` CertificateFile string `json:"cert" yaml:"cert"` KeyFile string `json:"key" yaml:"key"` } // Determines if functionality for allowing remote download of files into server directories // is enabled on this instance. If set to "true" remote downloads will not be possible for // servers. DisableRemoteDownload bool `json:"disable_remote_download" yaml:"disable_remote_download"` // The maximum size for files uploaded through the Panel in bytes. UploadLimit int `default:"100" json:"upload_limit" yaml:"upload_limit"` } // RemoteQueryConfiguration defines the configuration settings for remote requests // from Wings to the Panel. type RemoteQueryConfiguration struct { // The amount of time in seconds that Wings should allow for a request to the Panel API // to complete. If this time passes the request will be marked as failed. If your requests // are taking longer than 30 seconds to complete it is likely a performance issue that // should be resolved on the Panel, and not something that should be resolved by upping this // number. Timeout int `default:"30" yaml:"timeout"` // The number of servers to load in a single request to the Panel API when booting the // Wings instance. A single request is initially made to the Panel to get this number // of servers, and then the pagination status is checked and additional requests are // fired off in parallel to request the remaining pages. // // It is not recommended to change this from the default as you will likely encounter // memory limits on your Panel instance. In the grand scheme of things 4 requests for // 50 servers is likely just as quick as two for 100 or one for 400, and will certainly // be less likely to cause performance issues on the Panel. BootServersPerPage int `default:"50" yaml:"boot_servers_per_page"` } // SystemConfiguration defines basic system configuration settings. type SystemConfiguration struct { // The root directory where all of the pterodactyl data is stored at. RootDirectory string `default:"/var/lib/pterodactyl" yaml:"root_directory"` // Directory where logs for server installations and other wings events are logged. LogDirectory string `default:"/var/log/pterodactyl" yaml:"log_directory"` // Directory where the server data is stored at. Data string `default:"/var/lib/pterodactyl/volumes" yaml:"data"` // Directory where server archives for transferring will be stored. ArchiveDirectory string `default:"/var/lib/pterodactyl/archives" yaml:"archive_directory"` // Directory where local backups will be stored on the machine. BackupDirectory string `default:"/var/lib/pterodactyl/backups" yaml:"backup_directory"` // The user that should own all of the server files, and be used for containers. Username string `default:"pterodactyl" yaml:"username"` // The timezone for this Wings instance. This is detected by Wings automatically if possible, // and falls back to UTC if not able to be detected. If you need to set this manually, that // can also be done. // // This timezone value is passed into all containers created by Wings. Timezone string `yaml:"timezone"` // Definitions for the user that gets created to ensure that we can quickly access // this information without constantly having to do a system lookup. User struct { Uid int Gid int } // The amount of time in seconds that can elapse before a server's disk space calculation is // considered stale and a re-check should occur. DANGER: setting this value too low can seriously // impact system performance and cause massive I/O bottlenecks and high CPU usage for the Wings // process. // // Set to 0 to disable disk checking entirely. This will always return 0 for the disk space used // by a server and should only be set in extreme scenarios where performance is critical and // disk usage is not a concern. DiskCheckInterval int64 `default:"150" yaml:"disk_check_interval"` // If set to true, file permissions for a server will be checked when the process is // booted. This can cause boot delays if the server has a large amount of files. In most // cases disabling this should not have any major impact unless external processes are // frequently modifying a servers' files. CheckPermissionsOnBoot bool `default:"true" yaml:"check_permissions_on_boot"` // If set to false Wings will not attempt to write a log rotate configuration to the disk // when it boots and one is not detected. EnableLogRotate bool `default:"true" yaml:"enable_log_rotate"` // The number of lines to send when a server connects to the websocket. WebsocketLogCount int `default:"150" yaml:"websocket_log_count"` Sftp SftpConfiguration `yaml:"sftp"` CrashDetection CrashDetection `yaml:"crash_detection"` Backups Backups `yaml:"backups"` Transfers Transfers `yaml:"transfers"` } type CrashDetection struct { // Determines if Wings should detect a server that stops with a normal exit code of // "0" as being crashed if the process stopped without any Wings interaction. E.g. // the user did not press the stop button, but the process stopped cleanly. DetectCleanExitAsCrash bool `default:"true" yaml:"detect_clean_exit_as_crash"` // Timeout specifies the timeout between crashes that will not cause the server // to be automatically restarted, this value is used to prevent servers from // becoming stuck in a boot-loop after multiple consecutive crashes. Timeout int `default:"60" json:"timeout"` } type Backups struct { // WriteLimit imposes a Disk I/O write limit on backups to the disk, this affects all // backup drivers as the archiver must first write the file to the disk in order to // upload it to any external storage provider. // // If the value is less than 1, the write speed is unlimited, // if the value is greater than 0, the write speed is the value in MiB/s. // // Defaults to 0 (unlimited) WriteLimit int `default:"0" yaml:"write_limit"` } type Transfers struct { // DownloadLimit imposes a Network I/O read limit when downloading a transfer archive. // // If the value is less than 1, the write speed is unlimited, // if the value is greater than 0, the write speed is the value in MiB/s. // // Defaults to 0 (unlimited) DownloadLimit int `default:"0" yaml:"download_limit"` } type ConsoleThrottles struct { // Whether or not the throttler is enabled for this instance. Enabled bool `json:"enabled" yaml:"enabled" default:"true"` // The total number of lines that can be output in a given LineResetInterval period before // a warning is triggered and counted against the server. Lines uint64 `json:"lines" yaml:"lines" default:"2000"` // The total number of throttle activations that can accumulate before a server is considered // to be breaching and will be stopped. This value is decremented by one every DecayInterval. MaximumTriggerCount uint64 `json:"maximum_trigger_count" yaml:"maximum_trigger_count" default:"5"` // The amount of time after which the number of lines processed is reset to 0. This runs in // a constant loop and is not affected by the current console output volumes. By default, this // will reset the processed line count back to 0 every 100ms. LineResetInterval uint64 `json:"line_reset_interval" yaml:"line_reset_interval" default:"100"` // The amount of time in milliseconds that must pass without an output warning being triggered // before a throttle activation is decremented. DecayInterval uint64 `json:"decay_interval" yaml:"decay_interval" default:"10000"` // The amount of time that a server is allowed to be stopping for before it is terminated // forcefully if it triggers output throttles. StopGracePeriod uint `json:"stop_grace_period" yaml:"stop_grace_period" default:"15"` } type Configuration struct { // The location from which this configuration instance was instantiated. path string // Determines if wings should be running in debug mode. This value is ignored // if the debug flag is passed through the command line arguments. Debug bool // A unique identifier for this node in the Panel. Uuid string // An identifier for the token which must be included in any requests to the panel // so that the token can be looked up correctly. AuthenticationTokenId string `json:"token_id" yaml:"token_id"` // The token used when performing operations. Requests to this instance must // validate against it. AuthenticationToken string `json:"token" yaml:"token"` Api ApiConfiguration `json:"api" yaml:"api"` System SystemConfiguration `json:"system" yaml:"system"` Docker DockerConfiguration `json:"docker" yaml:"docker"` // Defines internal throttling configurations for server processes to prevent // someone from running an endless loop that spams data to logs. Throttles ConsoleThrottles // The location where the panel is running that this daemon should connect to // to collect data and send events. PanelLocation string `json:"remote" yaml:"remote"` RemoteQuery RemoteQueryConfiguration `json:"remote_query" yaml:"remote_query"` // AllowedMounts is a list of allowed host-system mount points. // This is required to have the "Server Mounts" feature work properly. AllowedMounts []string `json:"-" yaml:"allowed_mounts"` // AllowedOrigins is a list of allowed request origins. // The Panel URL is automatically allowed, this is only needed for adding // additional origins. AllowedOrigins []string `json:"allowed_origins" yaml:"allowed_origins"` } // NewAtPath creates a new struct and set the path where it should be stored. // This function does not modify the currently stored global configuration. func NewAtPath(path string) (*Configuration, error) { var c Configuration // Configures the default values for many of the configuration options present // in the structs. Values set in the configuration file take priority over the // default values. if err := defaults.Set(&c); err != nil { return nil, err } // Track the location where we created this configuration. c.path = path return &c, nil } // Set the global configuration instance. This is a blocking operation such that // anything trying to set a different configuration value, or read the configuration // will be paused until it is complete. func Set(c *Configuration) { mu.Lock() if _config == nil || _config.AuthenticationToken != c.AuthenticationToken { _jwtAlgo = jwt.NewHS256([]byte(c.AuthenticationToken)) } _config = c mu.Unlock() } // SetDebugViaFlag tracks if the application is running in debug mode because of // a command line flag argument. If so we do not want to store that configuration // change to the disk. func SetDebugViaFlag(d bool) { mu.Lock() _config.Debug = d _debugViaFlag = d mu.Unlock() } // Get returns the global configuration instance. This is a thread-safe operation // that will block if the configuration is presently being modified. // // Be aware that you CANNOT make modifications to the currently stored configuration // by modifying the struct returned by this function. The only way to make // modifications is by using the Update() function and passing data through in // the callback. func Get() *Configuration { mu.RLock() // Create a copy of the struct so that all modifications made beyond this // point are immutable. //goland:noinspection GoVetCopyLock c := *_config mu.RUnlock() return &c } // Update performs an in-situ update of the global configuration object using // a thread-safe mutex lock. This is the correct way to make modifications to // the global configuration. func Update(callback func(c *Configuration)) { mu.Lock() callback(_config) mu.Unlock() } // GetJwtAlgorithm returns the in-memory JWT algorithm. func GetJwtAlgorithm() *jwt.HMACSHA { mu.RLock() defer mu.RUnlock() return _jwtAlgo } // WriteToDisk writes the configuration to the disk. This is a thread safe operation // and will only allow one write at a time. Additional calls while writing are // queued up. func WriteToDisk(c *Configuration) error { _writeLock.Lock() defer _writeLock.Unlock() //goland:noinspection GoVetCopyLock ccopy := *c // If debugging is set with the flag, don't save that to the configuration file, // otherwise you'll always end up in debug mode. if _debugViaFlag { ccopy.Debug = false } if c.path == "" { return errors.New("cannot write configuration, no path defined in struct") } b, err := yaml.Marshal(&ccopy) if err != nil { return err } if err := ioutil.WriteFile(c.path, b, 0600); err != nil { return err } return nil } // EnsurePterodactylUser ensures that the Pterodactyl core user exists on the // system. This user will be the owner of all data in the root data directory // and is used as the user within containers. If files are not owned by this // user there will be issues with permissions on Docker mount points. // // This function IS NOT thread safe and should only be called in the main thread // when the application is booting. func EnsurePterodactylUser() error { sysName, err := getSystemName() if err != nil { return err } // Our way of detecting if wings is running inside of Docker. if sysName == "busybox" { _config.System.Username = system.FirstNotEmpty(os.Getenv("WINGS_USERNAME"), "pterodactyl") _config.System.User.Uid = system.MustInt(system.FirstNotEmpty(os.Getenv("WINGS_UID"), "988")) _config.System.User.Gid = system.MustInt(system.FirstNotEmpty(os.Getenv("WINGS_GID"), "988")) return nil } u, err := user.Lookup(_config.System.Username) // If an error is returned but it isn't the unknown user error just abort // the process entirely. If we did find a user, return it immediately. if err != nil { if _, ok := err.(user.UnknownUserError); !ok { return err } } else { _config.System.User.Uid = system.MustInt(u.Uid) _config.System.User.Gid = system.MustInt(u.Gid) return nil } command := fmt.Sprintf("useradd --system --no-create-home --shell /usr/sbin/nologin %s", _config.System.Username) // Alpine Linux is the only OS we currently support that doesn't work with the useradd // command, so in those cases we just modify the command a bit to work as expected. if strings.HasPrefix(sysName, "alpine") { command = fmt.Sprintf("adduser -S -D -H -G %[1]s -s /sbin/nologin %[1]s", _config.System.Username) // We have to create the group first on Alpine, so do that here before continuing on // to the user creation process. if _, err := exec.Command("addgroup", "-S", _config.System.Username).Output(); err != nil { return err } } split := strings.Split(command, " ") if _, err := exec.Command(split[0], split[1:]...).Output(); err != nil { return err } u, err = user.Lookup(_config.System.Username) if err != nil { return err } _config.System.User.Uid = system.MustInt(u.Uid) _config.System.User.Gid = system.MustInt(u.Gid) return nil } // FromFile reads the configuration from the provided file and stores it in the // global singleton for this instance. func FromFile(path string) error { b, err := ioutil.ReadFile(path) if err != nil { return err } c, err := NewAtPath(path) if err != nil { return err } // Replace environment variables within the configuration file with their // values from the host system. b = []byte(os.ExpandEnv(string(b))) if err := yaml.Unmarshal(b, c); err != nil { return err } // Store this configuration in the global state. Set(c) return nil } // ConfigureDirectories ensures that all of the system directories exist on the // system. These directories are created so that only the owner can read the data, // and no other users. // // This function IS NOT thread-safe. func ConfigureDirectories() error { root := _config.System.RootDirectory log.WithField("path", root).Debug("ensuring root data directory exists") if err := os.MkdirAll(root, 0700); err != nil { return err } // There are a non-trivial number of users out there whose data directories are actually a // symlink to another location on the disk. If we do not resolve that final destination at this // point things will appear to work, but endless errors will be encountered when we try to // verify accessed paths since they will all end up resolving outside the expected data directory. // // For the sake of automating away as much of this as possible, see if the data directory is a // symlink, and if so resolve to its final real path, and then update the configuration to use // that. if d, err := filepath.EvalSymlinks(_config.System.Data); err != nil { if !os.IsNotExist(err) { return err } } else if d != _config.System.Data { _config.System.Data = d } log.WithField("path", _config.System.Data).Debug("ensuring server data directory exists") if err := os.MkdirAll(_config.System.Data, 0700); err != nil { return err } log.WithField("path", _config.System.ArchiveDirectory).Debug("ensuring archive data directory exists") if err := os.MkdirAll(_config.System.ArchiveDirectory, 0700); err != nil { return err } log.WithField("path", _config.System.BackupDirectory).Debug("ensuring backup data directory exists") if err := os.MkdirAll(_config.System.BackupDirectory, 0700); err != nil { return err } return nil } // EnableLogRotation writes a logrotate file for wings to the system logrotate // configuration directory if one exists and a logrotate file is not found. This // allows us to basically automate away the log rotation for most installs, but // also enable users to make modifications on their own. // // This function IS NOT thread-safe. func EnableLogRotation() error { if !_config.System.EnableLogRotate { log.Info("skipping log rotate configuration, disabled in wings config file") return nil } if st, err := os.Stat("/etc/logrotate.d"); err != nil && !os.IsNotExist(err) { return err } else if (err != nil && os.IsNotExist(err)) || !st.IsDir() { return nil } if _, err := os.Stat("/etc/logrotate.d/wings"); err == nil || !os.IsNotExist(err) { return err } log.Info("no log rotation configuration found: adding file now") // If we've gotten to this point it means the logrotate directory exists on the system // but there is not a file for wings already. In that case, let us write a new file to // it so files can be rotated easily. f, err := os.Create("/etc/logrotate.d/wings") if err != nil { return err } defer f.Close() t, err := template.New("logrotate").Parse(` {{.LogDirectory}}/wings.log { size 10M compress delaycompress dateext maxage 7 missingok notifempty create 0640 {{.User.Uid}} {{.User.Gid}} postrotate killall -SIGHUP wings endscript }`) if err != nil { return err } return errors.Wrap(t.Execute(f, _config.System), "config: failed to write logrotate to disk") } // GetStatesPath returns the location of the JSON file that tracks server states. func (sc *SystemConfiguration) GetStatesPath() string { return path.Join(sc.RootDirectory, "/states.json") } // ConfigureTimezone sets the timezone data for the configuration if it is // currently missing. If a value has been set, this functionality will only run // to validate that the timezone being used is valid. // // This function IS NOT thread-safe. func ConfigureTimezone() error { tz := os.Getenv("TZ") if _config.System.Timezone == "" && tz != "" { _config.System.Timezone = tz } if _config.System.Timezone == "" { b, err := ioutil.ReadFile("/etc/timezone") if err != nil { if !os.IsNotExist(err) { return errors.WithMessage(err, "config: failed to open timezone file") } _config.System.Timezone = "UTC" ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) defer cancel() // Okay, file isn't found on this OS, we will try using timedatectl to handle this. If this // command fails, exit, but if it returns a value use that. If no value is returned we will // fall through to UTC to get Wings booted at least. out, err := exec.CommandContext(ctx, "timedatectl").Output() if err != nil { log.WithField("error", err).Warn("failed to execute \"timedatectl\" to determine system timezone, falling back to UTC") return nil } r := regexp.MustCompile(`Time zone: ([\w/]+)`) matches := r.FindSubmatch(out) if len(matches) != 2 || string(matches[1]) == "" { log.Warn("failed to parse timezone from \"timedatectl\" output, falling back to UTC") return nil } _config.System.Timezone = string(matches[1]) } else { _config.System.Timezone = string(b) } } _config.System.Timezone = regexp.MustCompile(`(?i)[^a-z_/]+`).ReplaceAllString(_config.System.Timezone, "") _, err := time.LoadLocation(_config.System.Timezone) return errors.WithMessage(err, fmt.Sprintf("the supplied timezone %s is invalid", _config.System.Timezone)) } // Gets the system release name. func getSystemName() (string, error) { // use osrelease to get release version and ID release, err := osrelease.Read() if err != nil { return "", err } return release["ID"], nil }