Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,20 @@
# Changelog

## v1.13.3
### Security
* SFTP `setstat` requests carrying the extended-attribute flag are now rejected before parsing, preventing a small packet from requesting an effectively unbounded memory allocation.
* Credential rotation is hardened: token values sent by the Panel may not use `file://` or `$VAR` indirection, and when `WINGS_TOKEN_ID`/`WINGS_TOKEN` environment overrides are set, a Panel-sent token that does not match them is rejected — so a configuration update can no longer leave Wings and the Panel using different keys.

### Fixed
* Master key resets through the Panel now propagate to the running daemon: applying a configuration update re-resolves the derived authentication token and rotates the Panel API client credentials, instead of using the boot-time token until the next restart. An update carrying an empty token is refused.
* Log rotation now signals Wings through the wrapper, fixing rotation when Wings runs under a supervisor ([#336](https://github.com/pterodactyl/wings/pull/336)).
* Reading a `file://` token source now surfaces the read error instead of silently yielding an empty token.

### Added
* `docker.cpu_period` — configurable CFS scheduling window (default 100000 µs, clamped to 1000–1000000).
* `docker.cpu_burst.percent` — lets containers bank unused CFS quota within a period and spend it in bursts; applied on start, install and in-situ limit updates (cgroup v1 and v2).
* `docker.cpu_shares` — relative CFS weight of server containers on a saturated host (default 0 = engine default; Wings historically hardcoded 1024, set that to restore the old bias towards host system services).

## v1.13.2
### Security
* Backup download, file download and file upload tokens are now checked against the revocation denylist. Previously only websocket tokens were, so revoking a user's access to a server left already-issued download and upload links working until they expired.
Expand Down
15 changes: 10 additions & 5 deletions FORK_CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ This file tracks **which changes are our own** (EmeraldHost-specific) versus ups
[`pterodactyl/wings`](https://github.com/pterodactyl/wings). Use it during upgrades so
our customizations are **not accidentally reverted** when pulling in upstream changes.

- **Baseline for this comparison:** upstream tag **`v1.13.2`** (`28af6dd`)
- **Last reviewed:** 2026-08-03
- **Baseline for this comparison:** upstream tag **`v1.13.3`** (`6987d5e`)
- **Last reviewed:** 2026-08-14
- **Module path:** this fork is `github.com/Rene-Roscher/wings` (upstream is
`github.com/pterodactyl/wings`). Version is injected at build time via ldflags
(`-X .../system.Version=<tag>`); `system/const.go` stays `develop` and is **not** a divergence.
Expand All @@ -24,8 +24,9 @@ our customizations are **not accidentally reverted** when pulling in upstream ch
> `server/server.go` and `sftp/server.go` will almost always conflict — resolve by **keeping ours**
> and grafting upstream's functional/security changes on top (that is exactly how v1.13.1 was merged).
>
> v1.13.2 was the exception: it only touched `router/tokens/**` plus three call sites and merged
> without a single conflict — see §4.
> v1.13.2 and v1.13.3 were exceptions: both stayed clear of the backup subsystem and merged
> (nearly) conflict-free — see §4. The v1.13.3 conflicts were only our rewritten
> `release.yaml` (keep ours, see §1.4) and the module-renamed import block of `server/install.go`.

---

Expand Down Expand Up @@ -101,7 +102,7 @@ our customizations are **not accidentally reverted** when pulling in upstream ch
|------|------|
| `.gitignore` | Fork-added `.claude-flow/`, `.hive-mind/`, `CLAUDE.md`. Upstream will never add these — keep on merge. |
| `Makefile`, `Dockerfile` | Our build settings (with the renamed module path). |
| `.github/workflows/{release,binary,docker}.yaml` | **Fork-specific release pipeline — always keep ours.** Upstream releases by hand: a human pushes a `v*` tag, `release.yaml` cuts a draft, a human publishes it. We release automatically from `develop` instead, and the version is derived from the newest **upstream** tag that is an ancestor of `develop` — so our releases always carry the upstream version number. Upstream's `release.yaml` has diverged beyond recognition; do not merge it. See the header comment in `release.yaml` for the full flow and recovery steps. |
| `.github/workflows/{release,binary,docker}.yaml` | **Fork-specific release pipeline — always keep ours.** Upstream releases by hand: a human pushes a `v*` tag, `release.yaml` cuts a draft, a human publishes it. We release automatically from `develop` instead, and the version is derived from the newest **upstream** tag that is an ancestor of `develop` — so our releases always carry the upstream version number. Upstream's `release.yaml` has diverged beyond recognition; do not merge it (v1.13.3's `c57c519` CDN-manifest notification was deliberately dropped — it notifies pterodactyl's own CDN repo). See the header comment in `release.yaml` for the full flow and recovery steps. |

---

Expand Down Expand Up @@ -141,6 +142,10 @@ fork changes risks duplicating or mis-merging them on the next upgrade.

| Path | Reality |
|------|---------|
| `config/config.go` → `ResolveToken(remote bool)`; `remote/http.go` → `Client.SetCredentials()` + mutex-guarded credentials; `router/router_system.go` → token re-resolve/empty-token guard/credential rotation in `postUpdateConfiguration` | **Upstream v1.13.3** master-key-rotation cluster (`07ce5fe`, `392e52c`, `3e6c2c9`): Panel-sent master key resets now propagate to the running daemon, with remote token values barred from `file://`/`$VAR` indirection and checked against `WINGS_TOKEN_ID`/`WINGS_TOKEN` overrides. Fork edit: module rename only. |
| `sftp/handler.go` → `setstatMode()` + `sftpAttributeExtended` rejection | **Upstream v1.13.3** (`da1a216`) hardening against unbounded allocations from crafted setstat packets. The fork's `publisher` wiring in `NewHandler` sits in the same file — both must survive a merge. |
| `environment/docker/cgroup_burst.go` (+ test), `applyCpuBurst`/`clearCpuBurst`/`SetCpuBurst` call sites in `container.go`/`power.go`/`server/install.go`, `config_docker.go` → `CpuPeriod`/`CpuBurst`/`CpuShares`, `environment/settings.go` quota math | **Upstream v1.13.3** CPU allocation work (`2cc8a10`, `6987d5e`). Not fork code — only the imports in the new files were renamed to `Rene-Roscher` (they arrive `pterodactyl` on every upstream merge; grep for leaks). |
| `config/config_token_test.go`, `remote/http_test.go`, `router/router_system_test.go`, `environment/docker/cgroup_burst_test.go`, `config/config_docker_test.go`, upstream additions in `sftp/handler_test.go` | **Upstream v1.13.3** suites, module rename only. Not fork suites. |
| `router/tokens/websocket.go` → `isDenylisted()`, and `Denylisted()` on `FilePayload` / `BackupPayload` / `UploadPayload` (+ their new `user_uuid` claim) | **Upstream v1.13.2** (`28af6dd`, "update token validation"). Revocation checking was extracted out of `WebsocketPayload.Denylisted()` into a shared `isDenylisted()` and applied to the backup-download, file-download and file-upload one-time tokens, which previously only checked `IsUniqueRequest()`/scope. Also tightened `Before(t)` → `!After(t)`, so a token issued in the same second as the revocation is now denied. All four files are byte-identical to upstream — **keep them that way**. |
| `router/tokens/denylist_test.go` | **Upstream v1.13.2**, unmodified. Covers the four payload types above. Not a fork suite. |
| `router/router_download.go`, `router/router_server_files.go` → the `token.Denylisted() \|\|` guards | **Upstream v1.13.2** call sites. The surrounding files *are* fork-modified (module rename + activity logging), so these three one-liners are easy to lose in a conflict resolution — check they survive. |
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,12 +136,12 @@ I would like to extend my sincere thanks to the following sponsors for helping f

| Company | About |
|-----------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| [**Aussie Server Hosts**](https://aussieserverhosts.com/) | No frills Australian Owned and operated High Performance Server hosting for some of the most demanding games serving Australia and New Zealand. |
| [**BisectHosting**](https://www.bisecthosting.com/) | BisectHosting provides Minecraft, Valheim and other server hosting services with the highest reliability and lightning fast support since 2012. |
| [**MineStrator**](https://minestrator.com/) | Looking for the most highend French hosting company for your minecraft server? More than 24,000 members on our discord trust us. Give us a try! |
| [**HostEZ**](https://hostez.io) | US & EU Rust & Minecraft Hosting. DDoS Protected bare metal, VPS and colocation with low latency, high uptime and maximum availability. EZ! |
| [**Blueprint**](https://blueprint.zip/?utm_source=pterodactyl&utm_medium=sponsor) | Create and install Pterodactyl addons and themes with the growing Blueprint framework - the package-manager for Pterodactyl. Use multiple modifications at once without worrying about conflicts and make use of the large extension ecosystem. |
| [**indifferent broccoli**](https://indifferentbroccoli.com/) | indifferent broccoli is a game server hosting and rental company. With us, you get top-notch computer power for your gaming sessions. We destroy lag, latency, and complexity--letting you focus on the fun stuff. |
| [**Infraly, LLC**](https://infraly.co/) | Infraly is an infrastructure company powering the next generation of online services. Through their brands, Infraly delivers cutting-edge solutions across multiple markets. Their vertically integrated approach provides unmatched performance, scalability, and reliability, giving our customers full control. |
| [**Hosturly**](https://hosturly.com/) | Hosturly is an enterprise hosting provider. They provide cost-effective, high-performance, and reliable services, including VPS, Web, Dedicated, and Colocation. |
| [**Physgun**](https://physgun.com/) | Physgun is a game server hosting provider. Most providers rent rack space and rebrand a panel. At Physgun, they engineer the performance, write the features, and staff the support. Physgun truly is game hosting perfected! |
| [**WISP**](https://wisp.gg/) | WISP is an industry-leading SaaS platform for game server management, designed for hosting companies, gaming organizations, and enthusiasts. WISP combines modern, intuitive interfaces with powerful tools, making server deployment and administration seamless, scalable, and efficient. |
| [**Buildurly**](https://buildurly.com/) | Buildurly is a hardware procurement company. They deliver tailored, enterprise-grade hardware solutions designed around your unique needs. From sourcing to delivery, Buildurly's white-glove service ensures a seamless, worry-free, professional experience. |
| [**indifferent broccoli**](https://indifferentbroccoli.com/) | indifferent broccoli is a game server hosting and rental company. With them, you get top-notch computer power for your gaming sessions. They destroy lag, latency, and complexity--letting you focus on the fun stuff. |

## Documentation

Expand Down
2 changes: 1 addition & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ func initLogging() {
if config.Get().Debug {
log.SetLevel(log.DebugLevel)
}
log.SetHandler(multi.New(cli.Default, cli.New(w.File, false)))
log.SetHandler(multi.New(cli.Default, cli.New(w, false)))
log.WithField("path", p).Info("writing log files to disk")
}

Expand Down
59 changes: 41 additions & 18 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,45 @@ func Set(c *Configuration) {
_config = c
}

// ResolveToken populates the derived Token field, preferring values pinned
// through the environment over those in the configuration itself.
//
// Set remote when the values came from the Panel. Local values may use
// "file://" or "$VAR" indirection; expanding one sent over the network would
// leak files and environment variables back out through the token we attach to
// every request. Environment overrides must already match remote values so a
// configuration update cannot leave Wings and the Panel using different keys.
func (c *Configuration) ResolveToken(remote bool) error {
resolve := func(name, env, local string) (string, error) {
if remote && (strings.Contains(local, "$") || strings.HasPrefix(local, "file://")) {
return "", fmt.Errorf("config: remote %s cannot use token indirection", name)
}
if env != "" {
value, err := Expand(env)
if err != nil {
return "", err
}
if remote && value != local {
return "", fmt.Errorf("config: remote %s does not match environment override", name)
}
return value, nil
}
if remote {
return local, nil
}
return Expand(local)
}

var err error
if c.Token.ID, err = resolve("token ID", os.Getenv("WINGS_TOKEN_ID"), c.AuthenticationTokenId); err != nil {
return err
}
if c.Token.Token, err = resolve("token", os.Getenv("WINGS_TOKEN"), c.AuthenticationToken); err != nil {
return err
}
return nil
}

// 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.
Expand Down Expand Up @@ -600,23 +639,7 @@ func FromFile(path string) error {
return err
}

c.Token = Token{
ID: os.Getenv("WINGS_TOKEN_ID"),
Token: os.Getenv("WINGS_TOKEN"),
}
if c.Token.ID == "" {
c.Token.ID = c.AuthenticationTokenId
}
if c.Token.Token == "" {
c.Token.Token = c.AuthenticationToken
}

c.Token.ID, err = Expand(c.Token.ID)
if err != nil {
return err
}
c.Token.Token, err = Expand(c.Token.Token)
if err != nil {
if err := c.ResolveToken(false); err != nil {
return err
}

Expand Down Expand Up @@ -860,7 +883,7 @@ func Expand(v string) (string, error) {

b, err := os.ReadFile(p)
if err != nil {
return "", nil
return "", err
}
v = string(bytes.TrimRight(bytes.TrimRight(b, "\r"), "\n"))
}
Expand Down
28 changes: 28 additions & 0 deletions config/config_docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,28 @@ type DockerConfiguration struct {
Cpu int64 `default:"100" json:"cpu" yaml:"cpu"`
} `json:"installer_limits" yaml:"installer_limits"`

// CpuPeriod is the length of a CFS scheduling window in microseconds. Server
// quotas scale with it, so the configured CPU limits stay the same. A shorter
// period reduces the worst case throttle latency at the cost of additional
// scheduler overhead.
CpuPeriod int64 `default:"100000" json:"cpu_period" yaml:"cpu_period"`

// CpuBurst allows containers to bank unused CFS quota within a period and spend
// it on short spikes without raising their long term CPU limit. Percent sizes the
// burst relative to a server's quota and is capped at 100 by the kernel. Requires
// Linux 5.14 or newer, it is skipped silently otherwise.
CpuBurst struct {
Enabled bool `default:"true" json:"enabled" yaml:"enabled"`
Percent int64 `default:"100" json:"percent" yaml:"percent"`
} `json:"cpu_burst" yaml:"cpu_burst"`

// CpuShares is the relative CFS weight of server containers when the host is
// fully saturated, it limits nothing on an idle host. Zero leaves containers
// at the engine default. Wings historically set 1024, which cgroup v2 converts
// to less than half of the default weight, set that value to restore the old
// bias towards host system services.
CpuShares int64 `default:"0" json:"cpu_shares" yaml:"cpu_shares"`

// Overhead controls the memory overhead given to all containers to circumvent certain
// software such as the JVM not staying below the maximum memory limit.
Overhead Overhead `json:"overhead" yaml:"overhead"`
Expand All @@ -97,6 +119,12 @@ type DockerConfiguration struct {
} `json:"log_config" yaml:"log_config"`
}

// CpuPeriodMicroseconds returns the configured CFS period clamped to the range
// the kernel accepts.
func (c DockerConfiguration) CpuPeriodMicroseconds() int64 {
return min(max(c.CpuPeriod, 1_000), 1_000_000)
}

func (c DockerConfiguration) ContainerLogConfig() container.LogConfig {
if c.LogConfig.Type == "" {
return container.LogConfig{}
Expand Down
22 changes: 22 additions & 0 deletions config/config_docker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,28 @@ func TestDockerRegistryCredentialsForImage(t *testing.T) {
}
}

func TestCpuPeriodMicroseconds(t *testing.T) {
tests := []struct {
name string
period int64
expected int64
}{
{name: "default period", period: 100_000, expected: 100_000},
{name: "shorter period", period: 20_000, expected: 20_000},
{name: "below kernel minimum", period: 500, expected: 1_000},
{name: "above kernel maximum", period: 5_000_000, expected: 1_000_000},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := DockerConfiguration{CpuPeriod: tt.period}
if v := cfg.CpuPeriodMicroseconds(); v != tt.expected {
t.Errorf("expected %d, got %d", tt.expected, v)
}
})
}
}

func TestDockerRegistryPathCredentialsDoNotMatchSiblingPath(t *testing.T) {
cfg := DockerConfiguration{
Registries: map[string]RegistryConfiguration{
Expand Down
Loading