diff --git a/.github/workflows/binary.yaml b/.github/workflows/binary.yaml new file mode 100644 index 00000000..0ceb7501 --- /dev/null +++ b/.github/workflows/binary.yaml @@ -0,0 +1,130 @@ +name: Build Binary + +# Two ways in: +# +# 1. Called by release.yaml as a reusable workflow, right after the draft +# release was opened. This is the automatic path, and it runs in the SAME +# workflow run — which is what makes it work at all: a release published +# with GITHUB_TOKEN does NOT trigger new workflow runs, so an `on: release` +# listener would silently never fire. +# +# 2. A manual dispatch, to check that a build still works without touching any +# release. On that path `inputs` is empty, every `inputs.*` reference below +# evaluates to '' rather than erroring, and the binaries are kept as run +# artifacts instead of being attached anywhere. + +on: + workflow_call: + inputs: + ref: + description: Commit SHA (or ref) to build. Defaults to the caller's ref. + type: string + required: false + default: '' + release-tag: + description: >- + Tag of an existing — possibly draft — GitHub Release to attach the + binaries and SHA256SUMS to. Empty means build and smoke-test only. + type: string + required: false + default: '' + version: + description: >- + Version to compile in, without a leading `v` (e.g. 1.13.2). Asserted + against what the built binary reports. + type: string + required: false + default: '' + workflow_dispatch: + +permissions: + contents: write + +jobs: + binary: + name: Build + runs-on: ubuntu-24.04 + steps: + - name: Code checkout + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + # Empty on dispatch, which makes actions/checkout fall back to its own + # default (the triggering ref and github.sha). + ref: ${{ inputs.ref }} + + - name: Setup Go + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 + with: + go-version: 1.24.11 + + - name: Resolve the version + id: resolve + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + # A dispatch build carries no version; label it by commit so a stray + # binary can never claim to be a release. + if [ -z "$VERSION" ]; then + VERSION="dev-$(git rev-parse --short HEAD)" + fi + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Build release binaries + env: + CGO_ENABLED: 0 + VERSION: ${{ steps.resolve.outputs.version }} + run: | + set -euo pipefail + for arch in amd64 arm64; do + GOARCH="$arch" go build \ + -o "dist/wings_linux_${arch}" \ + -v -trimpath \ + -ldflags="-s -w -X github.com/Rene-Roscher/wings/system.Version=${VERSION}" \ + github.com/Rene-Roscher/wings + chmod 755 "dist/wings_linux_${arch}" + done + + # Catches a broken ldflags path silently producing a binary that reports + # "develop" — which would otherwise only surface on a node, after release. + - name: Assert the binary reports the version it was built with + env: + VERSION: ${{ steps.resolve.outputs.version }} + run: | + set -euo pipefail + reported="$(./dist/wings_linux_amd64 version | head -n1)" + echo "$reported" + printf '%s' "$reported" | grep -qF "wings v${VERSION}" || { + echo "::error::binary reports '${reported}', expected 'wings v${VERSION}'" + exit 1 + } + + - name: Generate SHA256SUMS + run: | + set -euo pipefail + cd dist + sha256sum wings_linux_amd64 wings_linux_arm64 > SHA256SUMS + cat SHA256SUMS + + - name: Attach the assets to the release + if: inputs.release-tag != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ inputs.release-tag }} + run: | + set -euo pipefail + # --clobber so that re-running a failed run replaces partial uploads + # instead of failing on "asset already exists". + gh release upload "$TAG" \ + dist/wings_linux_amd64 \ + dist/wings_linux_arm64 \ + dist/SHA256SUMS \ + --clobber + + - name: Upload the binaries as run artifacts + if: inputs.release-tag == '' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: wings-binaries + path: dist/ diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 476cdfb3..58438626 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -1,25 +1,77 @@ name: Docker +# Called by release.yaml in the same run (a release published with +# GITHUB_TOKEN does not trigger `on: release`, so the old +# `on: release: published` listener would never fire once publishing is +# automated), and on its own for `:develop` images. +# +# Tag meanings: +# :1.13.2, :v1.13.2 — a published GitHub Release +# :latest — the newest published GitHub Release +# :develop — the newest push to develop +# +# This workflow deliberately does NOT push :latest. It is moved by release.yaml's +# publish job, by digest, only after the binaries, the image and the release +# assets have all been verified — otherwise a failure in any later job would +# leave :latest pointing at a build that never became a release. + on: + workflow_call: + inputs: + ref: + description: Commit SHA (or ref) to build. Defaults to the caller's ref. + type: string + required: false + default: '' + version: + description: >- + Release version without a leading `v` (e.g. 1.13.2). When set, the + image is tagged as a release build. + type: string + required: false + default: '' + outputs: + digest: + description: Digest of the pushed image, for retagging by the caller. + value: ${{ jobs.build.outputs.digest }} push: branches: - develop - release: - types: - - published + +permissions: + contents: read + packages: write + +concurrency: + group: docker-${{ inputs.version || github.ref }} + cancel-in-progress: false jobs: - build-and-push: + build: name: Build and Push runs-on: ubuntu-24.04 - # Always run against a tag, even if the commit into the tag has [docker skip] within the commit message. - if: "!contains(github.ref, 'develop') || (!contains(github.event.head_commit.message, 'skip docker') && !contains(github.event.head_commit.message, 'docker skip'))" - permissions: - contents: read - packages: write + # `skip docker` only applies to the develop path. A release build must never + # be skippable by a commit message, or publish would fail with no digest. + if: "inputs.version != '' || (!contains(github.event.head_commit.message, 'skip docker') && !contains(github.event.head_commit.message, 'docker skip'))" + outputs: + digest: ${{ steps.build.outputs.digest }} steps: - name: Code checkout uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + with: + ref: ${{ inputs.ref }} + + - name: Get build information + id: build_info + env: + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + if [ -n "$VERSION" ]; then + echo "build_version=${VERSION}" >> "$GITHUB_OUTPUT" + else + echo "build_version=dev-$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + fi - name: Docker metadata id: docker_meta @@ -29,9 +81,9 @@ jobs: flavor: | latest=false tags: | - type=raw,value=latest,enable=${{ github.event_name == 'release' && github.event.action == 'published' && github.event.release.prerelease == false }} - type=ref,event=tag - type=ref,event=branch + type=raw,value=${{ inputs.version }},enable=${{ inputs.version != '' }} + type=raw,value=v${{ inputs.version }},enable=${{ inputs.version != '' }} + type=ref,event=branch,enable=${{ inputs.version == '' }} - name: Setup QEMU uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 @@ -46,35 +98,16 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Get Build Information - id: build_info - run: | - echo "version_tag=${GITHUB_REF/refs\/tags\/v/}" >> "$GITHUB_OUTPUT" - echo "short_sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" - - - name: Build and Push (tag) + - name: Build and push + id: build uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 - if: "github.event_name == 'release' && github.event.action == 'published'" with: context: . file: ./Dockerfile push: true platforms: linux/amd64,linux/arm64 build-args: | - VERSION=${{ steps.build_info.outputs.version_tag }} - labels: ${{ steps.docker_meta.outputs.labels }} - tags: ${{ steps.docker_meta.outputs.tags }} - - - name: Build and Push (develop) - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 - if: "github.event_name == 'push' && contains(github.ref, 'develop')" - with: - context: . - file: ./Dockerfile - push: ${{ github.event_name != 'pull_request' }} - platforms: linux/amd64,linux/arm64 - build-args: | - VERSION=dev-${{ steps.build_info.outputs.short_sha }} + VERSION=${{ steps.build_info.outputs.build_version }} labels: ${{ steps.docker_meta.outputs.labels }} tags: ${{ steps.docker_meta.outputs.tags }} cache-from: type=gha diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 94d2e95e..a651a2d6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,57 +1,278 @@ name: Release + +# Automatic releases. The version is never ours to choose: it is always the +# version of the upstream pterodactyl/wings release that `develop` contains. +# +# push to develop +# └─ prepare resolves the newest upstream tag that is an ancestor of develop, +# refuses to continue without a matching CHANGELOG.md section, +# and opens a DRAFT release +# ├─ binary.yaml builds linux amd64/arm64 + SHA256SUMS onto the draft +# ├─ docker.yaml builds and pushes the versioned image +# └─ publish verifies the assets, moves :latest by digest, un-drafts +# +# Why draft-until-verified: nodes resolve GET /releases/latest and the :latest +# image. GitHub excludes drafts from /releases/latest, so a failure anywhere +# before `publish` leaves both pointing at the previous release rather than at a +# half-built one. Nothing becomes visible until the final job. +# +# Why binary/docker are `uses:` jobs in THIS run rather than `on: release` +# listeners: a release published with GITHUB_TOKEN does not trigger new workflow +# runs. docker.yaml used to listen on `release: published`, which only ever +# worked because a human clicked Publish. Automating that click without this +# change would have silently stopped every image build. +# +# NOTE: pushing a `v*` tag by hand no longer releases anything. Releases are cut +# from develop, by this workflow, and nowhere else. +# +# RECOVERY: a failed run leaves a DRAFT release behind. `prepare` treats an +# existing draft as "resume", so "Re-run failed jobs" on the original run picks +# up where it failed. Only a PUBLISHED release blocks a new run, which is why a +# fresh push to develop will not quietly re-cut a release that already shipped. +# +# RE-RELEASING a version: run this workflow manually with `force: true`. It +# deletes the published release AND its tag, then cuts the version again from +# the current develop. Deleting a release by hand does NOT re-trigger anything — +# that is deliberate, so an accidental deletion cannot resurrect itself with +# whatever happens to be on develop at the time. + on: push: - tags: - - "v*" + branches: + - develop + workflow_dispatch: + inputs: + force: + description: "Re-release the version even if it is already published (deletes the existing release and its tag)" + type: boolean + default: false + +permissions: + contents: write + packages: write + +concurrency: + group: release + cancel-in-progress: false + jobs: - release: - name: Release + prepare: + name: Prepare release runs-on: ubuntu-24.04 - permissions: - contents: write + outputs: + should_release: ${{ steps.decide.outputs.should_release }} + version: ${{ steps.upstream.outputs.version }} + tag: ${{ steps.upstream.outputs.tag }} + sha: ${{ github.sha }} steps: - name: Code checkout uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - - name: Setup Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: - go-version: 1.24.11 + # Ancestry checks need the whole history, not the default shallow clone. + fetch-depth: 0 - - name: Build release binaries - env: - CGO_ENABLED: 0 + - name: Resolve the upstream version contained in develop + id: upstream run: | - GOARCH=amd64 go build -o dist/wings_linux_amd64 -v -trimpath -ldflags="-s -w -X github.com/Rene-Roscher/wings/system.Version=${{ github.ref_name }}" github.com/Rene-Roscher/wings - chmod 755 dist/wings_linux_amd64 - GOARCH=arm64 go build -o dist/wings_linux_arm64 -v -trimpath -ldflags="-s -w -X github.com/Rene-Roscher/wings/system.Version=${{ github.ref_name }}" github.com/Rene-Roscher/wings - chmod 755 dist/wings_linux_arm64 + set -euo pipefail + git remote add upstream https://github.com/pterodactyl/wings.git + # Fetched into a private namespace on purpose: our fork carries tags + # with the SAME names pointing at our own merge commits (our v1.13.1 + # is f7ba42d, upstream's is e771816), so a plain `fetch --tags` is + # rejected as "would clobber existing tag". + git fetch --quiet upstream 'refs/tags/v*:refs/upstream-tags/v*' + + best="" + while read -r ref; do + version="${ref#refs/upstream-tags/v}" + # Upstream tags release candidates too (v1.11.0-rc.1). We only ever + # ship stable versions. + case "$version" in *-*) continue ;; esac + # `^{commit}` dereferences annotated tags to the commit they point at. + git merge-base --is-ancestor "$(git rev-parse "${ref}^{commit}")" HEAD || continue + if [ -z "$best" ] || [ "$(printf '%s\n%s\n' "$best" "$version" | sort -V | tail -n1)" = "$version" ]; then + best="$version" + fi + done < <(git for-each-ref --format='%(refname)' 'refs/upstream-tags/v*') - - name: Create release branch + if [ -z "$best" ]; then + echo "::error::no stable upstream tag is an ancestor of develop — cannot determine a version" + exit 1 + fi + + echo "develop contains upstream v${best}" + echo "version=${best}" >> "$GITHUB_OUTPUT" + echo "tag=v${best}" >> "$GITHUB_OUTPUT" + + - name: Decide whether to release + id: decide env: - VERSION: ${{ github.ref_name }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ steps.upstream.outputs.tag }} + FORCE: ${{ inputs.force }} run: | - BRANCH=release/${{ env.VERSION }} - git config --local user.email "ci@pterodactyl.io" - git config --local user.name "Pterodactyl CI" - git checkout -b $BRANCH - git push -u origin $BRANCH - sed -i "s/var Version = \".*\"/var Version = \"${VERSION:1}\"/" system/const.go - git add system/const.go - git commit -m "ci(release): bump version" - git push - - - name: write changelog + set -euo pipefail + state="$(gh release view "$TAG" --json isDraft \ + --jq 'if .isDraft then "draft" else "published" end' 2>/dev/null || echo none)" + + if [ "$FORCE" = "true" ] && [ "$state" != "none" ]; then + echo "::warning::${TAG} already exists (${state}) — force re-release requested, deleting it" + gh release delete "$TAG" --yes --cleanup-tag + state=none + fi + + case "$state" in + none) + echo "no release for ${TAG} yet — cutting it" + ;; + draft) + echo "a draft for ${TAG} exists — resuming it" + ;; + published) + echo "${TAG} is already published — nothing to do" + echo "should_release=false" >> "$GITHUB_OUTPUT" + { + echo "### No release needed" + echo "" + echo "develop is on upstream \`${TAG}\`, which is already published." + } >> "$GITHUB_STEP_SUMMARY" + exit 0 + ;; + esac + + echo "should_release=true" >> "$GITHUB_OUTPUT" + + - name: Extract the release notes + if: steps.decide.outputs.should_release == 'true' + env: + TAG: ${{ steps.upstream.outputs.tag }} run: | - sed -n "/^## ${{ github.ref_name }}/,/^## /{/^## /b;p}" CHANGELOG.md > ./RELEASE_CHANGELOG + set -euo pipefail + # Everything between this version's heading and the next one. The `$` + # anchor matters: without it `## v1.13.2` also matches `## v1.13.20`. + sed -n "/^## ${TAG}\$/,/^## /{/^## /b;p}" CHANGELOG.md > RELEASE_CHANGELOG + if ! grep -q '[^[:space:]]' RELEASE_CHANGELOG; then + echo "::error::CHANGELOG.md has no '## ${TAG}' section — refusing to publish a release with empty notes" + exit 1 + fi + cat RELEASE_CHANGELOG - - uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 + - name: Open the draft release + if: steps.decide.outputs.should_release == 'true' env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ steps.upstream.outputs.tag }} + SHA: ${{ github.sha }} + run: | + set -euo pipefail + # No git tag is created here. A draft only records tag_name and + # target_commitish; GitHub creates the tag when the release is + # published. That keeps an aborted run from leaving a tag behind. + if gh release view "$TAG" >/dev/null 2>&1; then + gh release edit "$TAG" --notes-file RELEASE_CHANGELOG --target "$SHA" + else + gh release create "$TAG" --draft --title "$TAG" --notes-file RELEASE_CHANGELOG --target "$SHA" + fi + { + echo "### Draft ${TAG} opened" + echo "" + echo "Building the binaries and the image now. The release is published" + echo "once both are attached and verified." + } >> "$GITHUB_STEP_SUMMARY" + + binary: + name: Binary + needs: prepare + if: needs.prepare.outputs.should_release == 'true' + uses: ./.github/workflows/binary.yaml + permissions: + contents: write + with: + ref: ${{ needs.prepare.outputs.sha }} + release-tag: ${{ needs.prepare.outputs.tag }} + version: ${{ needs.prepare.outputs.version }} + + image: + name: Image + needs: prepare + if: needs.prepare.outputs.should_release == 'true' + uses: ./.github/workflows/docker.yaml + permissions: + contents: read + packages: write + with: + ref: ${{ needs.prepare.outputs.sha }} + version: ${{ needs.prepare.outputs.version }} + + # Runs only once BOTH the binaries and the image succeeded. This is the single + # point where anything becomes visible: :latest moves and the release leaves + # draft state. Until then a failure anywhere leaves the previous release and + # the previous :latest completely untouched. + publish: + name: Publish release + needs: [prepare, binary, image] + if: needs.prepare.outputs.should_release == 'true' + runs-on: ubuntu-24.04 + permissions: + contents: write + packages: write + steps: + - name: Login to GitHub Container Registry + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 with: - draft: true - prerelease: ${{ contains(github.ref_name, 'rc') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'alpha') }} - body_path: ./RELEASE_CHANGELOG - files: | - dist/wings_linux_amd64 - dist/wings_linux_arm64 + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Verify first: read-only, cheap, and independent of the retag. Doing it + # before anything moves means the only thing that can fail after :latest + # has moved is a single idempotent call, which a re-run repairs. + - name: Verify the release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.prepare.outputs.tag }} + run: | + set -euo pipefail + names="$(gh release view "$TAG" --json assets --jq '.assets[].name')" + echo "attached assets:"; echo "$names" + for asset in wings_linux_amd64 wings_linux_arm64 SHA256SUMS; do + printf '%s\n' "$names" | grep -qxF "$asset" || { + echo "::error::release ${TAG} is missing asset '${asset}' — refusing to publish" + exit 1 + } + done + + # Retag by digest — no rebuild, and it cannot accidentally point at a + # different image than the one the `image` job just verified. The previous + # digest is logged so a manual rollback needs no archaeology. + - name: Move :latest to the verified release image + env: + IMAGE: ghcr.io/${{ github.repository }} + DIGEST: ${{ needs.image.outputs.digest }} + run: | + set -euo pipefail + test -n "$DIGEST" || { echo "::error::image job produced no digest"; exit 1; } + echo "previous :latest -> $(docker buildx imagetools inspect --format '{{.Manifest.Digest}}' "${IMAGE}:latest" 2>/dev/null || echo '(none)')" + docker buildx imagetools create --tag "${IMAGE}:latest" "${IMAGE}@${DIGEST}" + echo ":latest -> ${DIGEST}" + + - name: Publish the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.prepare.outputs.tag }} + VERSION: ${{ needs.prepare.outputs.version }} + run: | + set -euo pipefail + # Publishing is what finally creates the git tag, at the draft's + # target_commitish. + gh release edit "$TAG" --draft=false --latest + { + echo "### Published ${TAG}" + echo "" + echo "- Binaries: \`wings_linux_amd64\`, \`wings_linux_arm64\`, \`SHA256SUMS\`" + echo "- Image: \`ghcr.io/${{ github.repository }}:${VERSION}\` (also \`:${TAG}\` and \`:latest\`)" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index 58c781b3..e3fae999 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 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. +* Tokens that are missing the claims needed for that check (`iat`, `server_uuid`, `user_uuid`) are now rejected outright instead of being accepted. + +### Changed +* Backup downloads, file downloads and file uploads now require a Panel that sends the new `user_uuid` claim in those tokens. **Update the Panel before Wings** — against an older Panel these requests return `404`. + ## v1.13.1 ### Security * Backup restore downloads are now hardened against SSRF: remote restore links are validated and may not resolve to private, loopback, link-local or other blocked address ranges unless permitted via the new `restore_host_allowlist` config option. diff --git a/FORK_CHANGES.md b/FORK_CHANGES.md index 69190136..70ba7906 100644 --- a/FORK_CHANGES.md +++ b/FORK_CHANGES.md @@ -96,7 +96,8 @@ our customizations are **not accidentally reverted** when pulling in upstream ch | Path | What | |------|------| | `.gitignore` | Fork-added `.claude-flow/`, `.hive-mind/`, `CLAUDE.md`. Upstream will never add these — keep on merge. | -| `.github/workflows/**`, `Makefile`, `Dockerfile` | Our build/release pipeline (with the renamed module path). | +| `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. | ---