From 7d620aa3b5ee8d0167b0903e149b3c90eeb66524 Mon Sep 17 00:00:00 2001 From: HanGYvv Date: Fri, 12 Jun 2026 12:55:25 +0800 Subject: [PATCH 1/7] ci: rework release pipeline into modular workflows Split the monolithic build workflow into separate ci, debian, docker, and release workflows backed by reusable composite actions, and add a release-notes helper binary. --- .github/actions/build-rust-target/action.yml | 44 ++ .../actions/docker-build-and-push/action.yml | 148 +++++ .github/actions/docker-manifest/action.yml | 126 ++++ .github/workflows/build.yaml | 602 ------------------ .github/workflows/ci.yaml | 68 ++ .github/workflows/debian.yaml | 152 +++++ .github/workflows/docker.yaml | 310 +++++++++ .github/workflows/release.yaml | 262 ++++++++ Cargo.toml | 4 + src/bin/release-notes.rs | 466 ++++++++++++++ 10 files changed, 1580 insertions(+), 602 deletions(-) create mode 100644 .github/actions/build-rust-target/action.yml create mode 100644 .github/actions/docker-build-and-push/action.yml create mode 100644 .github/actions/docker-manifest/action.yml delete mode 100644 .github/workflows/build.yaml create mode 100644 .github/workflows/ci.yaml create mode 100644 .github/workflows/debian.yaml create mode 100644 .github/workflows/docker.yaml create mode 100644 .github/workflows/release.yaml create mode 100644 src/bin/release-notes.rs diff --git a/.github/actions/build-rust-target/action.yml b/.github/actions/build-rust-target/action.yml new file mode 100644 index 000000000..57d53af39 --- /dev/null +++ b/.github/actions/build-rust-target/action.yml @@ -0,0 +1,44 @@ +name: Build Rust target +description: Install a Rust toolchain and build release binaries for one target. + +inputs: + target: + description: Rust target triple. + required: true + toolchain: + description: Rust toolchain version. + required: true + use-cross: + description: Use cross instead of cargo for the build. + required: false + default: "false" + working-directory: + description: Directory where cargo or cross should run. + required: false + default: "." + +runs: + using: composite + steps: + - name: Install toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ inputs.toolchain }} + components: rustfmt + targets: ${{ inputs.target }} + + - name: Install cross + if: inputs.use-cross == 'true' + uses: taiki-e/install-action@cross + + - name: Build with cross + if: inputs.use-cross == 'true' + shell: bash + working-directory: ${{ inputs.working-directory }} + run: cross build --release --all-features --target=${{ inputs.target }} + + - name: Build with cargo + if: inputs.use-cross != 'true' + shell: bash + working-directory: ${{ inputs.working-directory }} + run: cargo build --release --all-features --target=${{ inputs.target }} diff --git a/.github/actions/docker-build-and-push/action.yml b/.github/actions/docker-build-and-push/action.yml new file mode 100644 index 000000000..aab214803 --- /dev/null +++ b/.github/actions/docker-build-and-push/action.yml @@ -0,0 +1,148 @@ +name: Build and push Docker image +description: Build a single-architecture Docker image from downloaded Rust binaries. + +inputs: + artifact-name: + description: GitHub artifact name containing the binaries. + required: true + artifact-path: + description: Directory where the binaries should be downloaded. + required: true + context: + description: Docker build context. + required: true + docker-platform: + description: Docker platform to build. + required: true + docker-hub-image: + description: Docker Hub image name. + required: true + ghcr-image: + description: GHCR image name. + required: true + image-title: + description: Human-readable image title used in OCI metadata. + required: true + image-description: + description: Human-readable image description used in OCI metadata. + required: true + project-url: + description: Project homepage URL used in OCI metadata. + required: true + documentation-url: + description: Documentation URL used in OCI metadata. + required: true + arch-name: + description: Architecture suffix used in tags. + required: true + s6-arch: + description: s6-overlay architecture build argument. Leave empty for classic images. + required: false + default: "" + version-tag: + description: Exact version tag for immutable image tags. + required: true + major-tag: + description: Major version tag without a leading v. + required: true + latest-tag: + description: Floating release tag. + required: true + docker-hub-username: + description: Docker Hub username. + required: true + docker-hub-password: + description: Docker Hub token. + required: true + ghcr-username: + description: GHCR username. + required: true + ghcr-token: + description: GHCR token. + required: true + +runs: + using: composite + steps: + - name: Download binaries + uses: actions/download-artifact@v6 + with: + name: ${{ inputs.artifact-name }} + path: ${{ inputs.artifact-path }} + + - name: Make binaries executable + shell: bash + run: chmod -v a+x "${{ inputs.artifact-path }}"/* + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ inputs.docker-hub-username }} + password: ${{ inputs.docker-hub-password }} + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ inputs.ghcr-username }} + password: ${{ inputs.ghcr-token }} + + - name: Resolve source revision + id: revision + shell: bash + run: | + source_dir="$(dirname "${{ inputs.context }}")" + echo "sha=$(git -C "${source_dir}" rev-parse HEAD)" >> "${GITHUB_OUTPUT}" + + - name: Resolve created timestamp + id: created + shell: bash + run: | + echo "value=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}" + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: registry.hub.docker.com/${{ inputs.docker-hub-image }} + labels: | + org.opencontainers.image.title=${{ inputs.image-title }} + org.opencontainers.image.description=${{ inputs.image-description }} + org.opencontainers.image.url=${{ inputs.project-url }} + org.opencontainers.image.source=${{ inputs.project-url }} + org.opencontainers.image.version=${{ inputs.version-tag }} + org.opencontainers.image.revision=${{ steps.revision.outputs.sha }} + org.opencontainers.image.created=${{ steps.created.outputs.value }} + org.opencontainers.image.licenses=AGPL-3.0-or-later + org.opencontainers.image.documentation=${{ inputs.documentation-url }} + + - name: Resolve build args + id: build-args + shell: bash + run: | + if [ -n "${{ inputs.s6-arch }}" ]; then + echo "value=S6_ARCH=${{ inputs.s6-arch }}" >> "${GITHUB_OUTPUT}" + fi + + - name: Build and push Docker image + uses: docker/build-push-action@v7 + with: + context: ${{ inputs.context }} + platforms: ${{ inputs.docker-platform }} + push: true + provenance: false + build-args: ${{ steps.build-args.outputs.value }} + tags: | + ${{ inputs.docker-hub-image }}:${{ inputs.latest-tag }}-${{ inputs.arch-name }} + ${{ inputs.docker-hub-image }}:${{ inputs.version-tag }}-${{ inputs.arch-name }} + ${{ inputs.docker-hub-image }}:${{ inputs.major-tag }}-${{ inputs.arch-name }} + ${{ inputs.ghcr-image }}:${{ inputs.latest-tag }}-${{ inputs.arch-name }} + ${{ inputs.ghcr-image }}:${{ inputs.version-tag }}-${{ inputs.arch-name }} + ${{ inputs.ghcr-image }}:${{ inputs.major-tag }}-${{ inputs.arch-name }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/actions/docker-manifest/action.yml b/.github/actions/docker-manifest/action.yml new file mode 100644 index 000000000..fc76ae219 --- /dev/null +++ b/.github/actions/docker-manifest/action.yml @@ -0,0 +1,126 @@ +name: Create Docker manifests +description: Create Docker Hub and GHCR multi-architecture manifests. + +inputs: + docker-hub-image: + description: Docker Hub image name. + required: true + ghcr-image: + description: GHCR image name. + required: true + source-path: + description: Checked out repository path used to resolve the image revision. + required: true + image-title: + description: Human-readable image title used in OCI metadata. + required: true + image-description: + description: Human-readable image description used in OCI metadata. + required: true + project-url: + description: Project homepage URL used in OCI metadata. + required: true + documentation-url: + description: Documentation URL used in OCI metadata. + required: true + arch-suffixes: + description: Space-separated architecture suffixes. + required: true + version-tag: + description: Exact version tag for immutable image tags. + required: true + major-tag: + description: Major version tag without a leading v. + required: true + latest-tag: + description: Floating release tag. + required: true + docker-hub-username: + description: Docker Hub username. + required: true + docker-hub-password: + description: Docker Hub token. + required: true + ghcr-username: + description: GHCR username. + required: true + ghcr-token: + description: GHCR token. + required: true + +runs: + using: composite + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ inputs.docker-hub-username }} + password: ${{ inputs.docker-hub-password }} + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ inputs.ghcr-username }} + password: ${{ inputs.ghcr-token }} + + - name: Resolve source revision + id: revision + shell: bash + run: | + echo "sha=$(git -C "${{ inputs.source-path }}" rev-parse HEAD)" >> "${GITHUB_OUTPUT}" + + - name: Resolve created timestamp + id: created + shell: bash + run: | + echo "value=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "${GITHUB_OUTPUT}" + + - name: Create manifests + shell: bash + env: + DOCKER_HUB_IMAGE: ${{ inputs.docker-hub-image }} + GHCR_IMAGE: ${{ inputs.ghcr-image }} + IMAGE_TITLE: ${{ inputs.image-title }} + IMAGE_DESCRIPTION: ${{ inputs.image-description }} + PROJECT_URL: ${{ inputs.project-url }} + DOCUMENTATION_URL: ${{ inputs.documentation-url }} + ARCH_SUFFIXES: ${{ inputs.arch-suffixes }} + VERSION_TAG: ${{ inputs.version-tag }} + MAJOR_TAG: ${{ inputs.major-tag }} + LATEST_TAG: ${{ inputs.latest-tag }} + REVISION: ${{ steps.revision.outputs.sha }} + CREATED: ${{ steps.created.outputs.value }} + run: | + create_manifest() { + local image="$1" + local tag="$2" + local sources="" + + for arch in ${ARCH_SUFFIXES}; do + sources="${sources} ${image}:${tag}-${arch}" + done + + docker buildx imagetools create \ + --tag "${image}:${tag}" \ + --annotation "index:org.opencontainers.image.title=${IMAGE_TITLE}" \ + --annotation "index:org.opencontainers.image.description=${IMAGE_DESCRIPTION}" \ + --annotation "index:org.opencontainers.image.url=${PROJECT_URL}" \ + --annotation "index:org.opencontainers.image.source=${PROJECT_URL}" \ + --annotation "index:org.opencontainers.image.version=${VERSION_TAG}" \ + --annotation "index:org.opencontainers.image.revision=${REVISION}" \ + --annotation "index:org.opencontainers.image.created=${CREATED}" \ + --annotation "index:org.opencontainers.image.licenses=AGPL-3.0-or-later" \ + --annotation "index:org.opencontainers.image.documentation=${DOCUMENTATION_URL}" \ + ${sources} + } + + create_manifest "${DOCKER_HUB_IMAGE}" "${VERSION_TAG}" + create_manifest "${GHCR_IMAGE}" "${VERSION_TAG}" + create_manifest "${DOCKER_HUB_IMAGE}" "${MAJOR_TAG}" + create_manifest "${GHCR_IMAGE}" "${MAJOR_TAG}" + create_manifest "${DOCKER_HUB_IMAGE}" "${LATEST_TAG}" + create_manifest "${GHCR_IMAGE}" "${LATEST_TAG}" diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml deleted file mode 100644 index 6df15384c..000000000 --- a/.github/workflows/build.yaml +++ /dev/null @@ -1,602 +0,0 @@ -name: build - -# ------------- NOTE -# please setup some secrets before running this workflow: -# DOCKER_IMAGE should be the target image name on docker hub (e.g. "rustdesk/rustdesk-server-s6" ) -# DOCKER_IMAGE_CLASSIC should be the target image name on docker hub for the old build (e.g. "rustdesk/rustdesk-server" ) -# DOCKER_HUB_USERNAME is the username you normally use to login at https://hub.docker.com/ -# DOCKER_HUB_PASSWORD is a token you should create under "account settings / security" with read/write access - -permissions: - contents: read - packages: write - -on: - workflow_dispatch: - push: - tags: - - 'v[0-9]+.[0-9]+.[0-9]+' - - '[0-9]+.[0-9]+.[0-9]+' - - 'v[0-9]+.[0-9]+.[0-9]+-[0-9]+' - - '[0-9]+.[0-9]+.[0-9]+-[0-9]+' - -env: - CARGO_TERM_COLOR: always - LATEST_TAG: latest - GHCR_IMAGE: ghcr.io/rustdesk/rustdesk-server-s6 - GHCR_IMAGE_CLASSIC: ghcr.io/rustdesk/rustdesk-server - -jobs: - - # binary build - build: - - name: Build - ${{ matrix.job.name }} - runs-on: ubuntu-22.04 - strategy: - fail-fast: false - matrix: - job: - - { name: "amd64", target: "x86_64-unknown-linux-musl" } - - { name: "arm64v8", target: "aarch64-unknown-linux-musl" } - - { name: "armv7", target: "armv7-unknown-linux-musleabihf" } - - { name: "i386", target: "i686-unknown-linux-musl" } - #- { name: "amd64fb", target: "x86_64-unknown-freebsd" } - - steps: - - - name: Checkout - uses: actions/checkout@v3 - with: - submodules: recursive - - - name: Install toolchain - uses: actions-rs/toolchain@v1 - with: - toolchain: "stable" - override: true - default: true - components: rustfmt - profile: minimal - target: ${{ matrix.job.target }} - - - name: Build - uses: actions-rs/cargo@v1 - with: - command: build - args: --release --all-features --target=${{ matrix.job.target }} - use-cross: true - - - name: Exec chmod - run: chmod -v a+x target/${{ matrix.job.target }}/release/* - - - name: Publish Artifacts - uses: actions/upload-artifact@v4 - with: - name: binaries-linux-${{ matrix.job.name }} - path: | - target/${{ matrix.job.target }}/release/hbbr - target/${{ matrix.job.target }}/release/hbbs - target/${{ matrix.job.target }}/release/rustdesk-utils - if-no-files-found: error - - build-win: - name: Build - windows - runs-on: windows-2022 - - steps: - - - name: Checkout - uses: actions/checkout@v3 - with: - submodules: recursive - - - name: Install toolchain - uses: actions-rs/toolchain@v1 - with: - toolchain: "stable" - override: true - default: true - components: rustfmt - profile: minimal - target: x86_64-pc-windows-msvc - - - name: Build - uses: actions-rs/cargo@v1 - with: - command: build - args: --release --all-features --target=x86_64-pc-windows-msvc - use-cross: true - - - name: Install NSIS - run: | - iwr -useb get.scoop.sh -outfile 'install.ps1' - .\install.ps1 -RunAsAdmin - scoop update - scoop bucket add extras - scoop install nsis - - - name: Install Node.js - uses: actions/setup-node@v3 - with: - node-version: 16 - - - name: Sign exe files - uses: GermanBluefox/code-sign-action@v7 - if: false - with: - certificate: '${{ secrets.WINDOWS_PFX_BASE64 }}' - password: '${{ secrets.WINDOWS_PFX_PASSWORD }}' - certificatesha1: '${{ secrets.WINDOWS_PFX_SHA1_THUMBPRINT }}' - folder: 'target\x86_64-pc-windows-msvc\release' - recursive: false - - - name: Build UI browser file - run: | - npm i - npm run build - working-directory: ./ui/html - - - name: Build UI setup file - run: | - rustup default nightly - cargo build --release - xcopy /y ..\target\x86_64-pc-windows-msvc\release\*.exe setup\bin\ - xcopy /y target\release\*.exe setup\ - mkdir setup\logs - makensis /V1 setup.nsi - mkdir SignOutput - mv RustDeskServer.Setup.exe SignOutput\ - mv ..\target\x86_64-pc-windows-msvc\release\*.exe SignOutput\ - working-directory: ./ui - - - name: Sign UI setup file - uses: GermanBluefox/code-sign-action@v7 - if: false - with: - certificate: '${{ secrets.WINDOWS_PFX_BASE64 }}' - password: '${{ secrets.WINDOWS_PFX_PASSWORD }}' - certificatesha1: '${{ secrets.WINDOWS_PFX_SHA1_THUMBPRINT }}' - folder: './ui/SignOutput' - recursive: false - - - name: Publish Artifacts - uses: actions/upload-artifact@v4 - with: - name: binaries-windows-x86_64 - path: | - ui\SignOutput\hbbr.exe - ui\SignOutput\hbbs.exe - ui\SignOutput\rustdesk-utils.exe - ui\SignOutput\RustDeskServer.Setup.exe - if-no-files-found: error - - # github (draft) release with all binaries - release: - - name: Github release - needs: - - build - - build-win - runs-on: ubuntu-22.04 - strategy: - fail-fast: false - matrix: - job: - - { os: "linux", name: "amd64", suffix: "" } - - { os: "linux", name: "arm64v8", suffix: "" } - - { os: "linux", name: "armv7", suffix: "" } - - { os: "linux", name: "i386", suffix: "" } - #- { os: "linux", name: "amd64fb", suffix: "" } - - { os: "windows", name: "x86_64", suffix: "-unsigned" } - - steps: - - - name: Download binaries (${{ matrix.job.os }} - ${{ matrix.job.name }}) - uses: actions/download-artifact@v4 - with: - name: binaries-${{ matrix.job.os }}-${{ matrix.job.name }} - path: ${{ matrix.job.name }} - - - name: Exec chmod - run: chmod -v a+x ${{ matrix.job.name }}/* - - - name: Pack files (${{ matrix.job.os }} - ${{ matrix.job.name }}) - run: | - sudo apt update - DEBIAN_FRONTEND=noninteractive sudo apt install -y zip - zip ${{ matrix.job.name }}/rustdesk-server-${{ matrix.job.os }}-${{ matrix.job.name }}${{ matrix.job.suffix }}.zip ${{ matrix.job.name }}/* - - - name: Create Release (${{ matrix.job.os }} - (${{ matrix.job.name }}) - uses: softprops/action-gh-release@v1 - with: - draft: true - files: ${{ matrix.job.name }}/rustdesk-server-${{ matrix.job.os }}-${{ matrix.job.name }}${{ matrix.job.suffix }}.zip - - # docker build and push of single-arch images - docker: - - name: Docker push - ${{ matrix.job.name }} - needs: build - runs-on: ubuntu-22.04 - strategy: - fail-fast: false - matrix: - job: - - { name: "amd64", docker_platform: "linux/amd64", s6_platform: "x86_64" } - - { name: "arm64v8", docker_platform: "linux/arm64", s6_platform: "aarch64" } - - { name: "armv7", docker_platform: "linux/arm/v7", s6_platform: "armhf" } - - { name: "i386", docker_platform: "linux/386", s6_platform: "i686" } - - steps: - - - name: Checkout - uses: actions/checkout@v3 - with: - submodules: recursive - - - name: Download binaries - uses: actions/download-artifact@v4 - with: - name: binaries-linux-${{ matrix.job.name }} - path: docker/rootfs/usr/bin - - - name: Make binaries executable - run: chmod -v a+x docker/rootfs/usr/bin/* - - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Log in to Docker Hub - if: github.event_name != 'pull_request' - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_PASSWORD }} - - - name: Log in to GHCR - if: github.event_name != 'pull_request' - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: rustdesk - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v4 - with: - images: registry.hub.docker.com/${{ secrets.DOCKER_IMAGE }} - - - name: Get git tag - id: vars - run: | - T=${GITHUB_REF#refs/*/} - M=${T%%.*} - echo "GIT_TAG=$T" >> $GITHUB_ENV - echo "MAJOR_TAG=$M" >> $GITHUB_ENV - - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: "./docker" - platforms: ${{ matrix.job.docker_platform }} - push: true - provenance: false - build-args: | - S6_ARCH=${{ matrix.job.s6_platform }} - tags: | - ${{ secrets.DOCKER_IMAGE }}:${{ env.LATEST_TAG }}-${{ matrix.job.name }} - ${{ secrets.DOCKER_IMAGE }}:${{ env.GIT_TAG }}-${{ matrix.job.name }} - ${{ secrets.DOCKER_IMAGE }}:${{ env.MAJOR_TAG }}-${{ matrix.job.name }} - ${{ env.GHCR_IMAGE }}:${{ env.LATEST_TAG }}-${{ matrix.job.name }} - ${{ env.GHCR_IMAGE }}:${{ env.GIT_TAG }}-${{ matrix.job.name }} - ${{ env.GHCR_IMAGE }}:${{ env.MAJOR_TAG }}-${{ matrix.job.name }} - labels: ${{ steps.meta.outputs.labels }} - - # docker build and push of multiarch images - docker-manifest: - - name: Docker manifest - needs: docker - runs-on: ubuntu-22.04 - - steps: - - - name: Log in to Docker Hub - if: github.event_name != 'pull_request' - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_PASSWORD }} - - - name: Get git tag - id: vars - run: | - T=${GITHUB_REF#refs/*/} - M=${T%%.*} - echo "GIT_TAG=$T" >> $GITHUB_ENV - echo "MAJOR_TAG=$M" >> $GITHUB_ENV - - - name: Log in to GHCR - if: github.event_name != 'pull_request' - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: rustdesk - password: ${{ secrets.GITHUB_TOKEN }} - - # manifest for :1.2.3 tag - # this has to run only if invoked by a new tag - - name: Create and push manifest (:ve.rs.ion) - uses: Noelware/docker-manifest-action@0.4.3 - if: github.event_name != 'workflow_dispatch' - with: - base-image: ${{ secrets.DOCKER_IMAGE }}:${{ env.GIT_TAG }} - extra-images: ${{ secrets.DOCKER_IMAGE }}:${{ env.GIT_TAG }}-amd64,${{ secrets.DOCKER_IMAGE }}:${{ env.GIT_TAG }}-arm64v8,${{ secrets.DOCKER_IMAGE }}:${{ env.GIT_TAG }}-armv7,${{ secrets.DOCKER_IMAGE }}:${{ env.GIT_TAG }}-i386 - push: true - - # manifest for :1 tag (major release) - - name: Create and push manifest (:major) - uses: Noelware/docker-manifest-action@0.4.3 - with: - base-image: ${{ secrets.DOCKER_IMAGE }}:${{ env.MAJOR_TAG }} - extra-images: ${{ secrets.DOCKER_IMAGE }}:${{ env.MAJOR_TAG }}-amd64,${{ secrets.DOCKER_IMAGE }}:${{ env.MAJOR_TAG }}-arm64v8,${{ secrets.DOCKER_IMAGE }}:${{ env.MAJOR_TAG }}-armv7,${{ secrets.DOCKER_IMAGE }}:${{ env.MAJOR_TAG }}-i386 - push: true - - # manifest for :latest tag - - name: Create and push manifest (:latest) - uses: Noelware/docker-manifest-action@0.4.3 - with: - base-image: ${{ secrets.DOCKER_IMAGE }}:${{ env.LATEST_TAG }} - extra-images: ${{ secrets.DOCKER_IMAGE }}:${{ env.LATEST_TAG }}-amd64,${{ secrets.DOCKER_IMAGE }}:${{ env.LATEST_TAG }}-arm64v8,${{ secrets.DOCKER_IMAGE }}:${{ env.LATEST_TAG }}-armv7,${{ secrets.DOCKER_IMAGE }}:${{ env.LATEST_TAG }}-i386 - push: true - - # GHCR manifests - # manifest for :1.2.3 tag - - name: Create and push GHCR manifest (:ve.rs.ion) - uses: Noelware/docker-manifest-action@0.4.3 - if: github.event_name != 'workflow_dispatch' - with: - base-image: ${{ env.GHCR_IMAGE }}:${{ env.GIT_TAG }} - extra-images: ${{ env.GHCR_IMAGE }}:${{ env.GIT_TAG }}-amd64,${{ env.GHCR_IMAGE }}:${{ env.GIT_TAG }}-arm64v8,${{ env.GHCR_IMAGE }}:${{ env.GIT_TAG }}-armv7,${{ env.GHCR_IMAGE }}:${{ env.GIT_TAG }}-i386 - push: true - - # manifest for :1 tag (major release) - - name: Create and push GHCR manifest (:major) - uses: Noelware/docker-manifest-action@0.4.3 - with: - base-image: ${{ env.GHCR_IMAGE }}:${{ env.MAJOR_TAG }} - extra-images: ${{ env.GHCR_IMAGE }}:${{ env.MAJOR_TAG }}-amd64,${{ env.GHCR_IMAGE }}:${{ env.MAJOR_TAG }}-arm64v8,${{ env.GHCR_IMAGE }}:${{ env.MAJOR_TAG }}-armv7,${{ env.GHCR_IMAGE }}:${{ env.MAJOR_TAG }}-i386 - push: true - - # manifest for :latest tag - - name: Create and push GHCR manifest (:latest) - uses: Noelware/docker-manifest-action@0.4.3 - with: - base-image: ${{ env.GHCR_IMAGE }}:${{ env.LATEST_TAG }} - extra-images: ${{ env.GHCR_IMAGE }}:${{ env.LATEST_TAG }}-amd64,${{ env.GHCR_IMAGE }}:${{ env.LATEST_TAG }}-arm64v8,${{ env.GHCR_IMAGE }}:${{ env.LATEST_TAG }}-armv7,${{ env.GHCR_IMAGE }}:${{ env.LATEST_TAG }}-i386 - push: true - - - # docker build and push of single-arch images - docker-classic: - - name: Docker push - ${{ matrix.job.name }} - needs: build - runs-on: ubuntu-22.04 - strategy: - fail-fast: false - matrix: - job: - - { name: "amd64", docker_platform: "linux/amd64" } - - { name: "arm64v8", docker_platform: "linux/arm64" } - - { name: "armv7", docker_platform: "linux/arm/v7" } - - steps: - - - name: Checkout - uses: actions/checkout@v3 - with: - submodules: recursive - - - name: Download binaries - uses: actions/download-artifact@v4 - with: - name: binaries-linux-${{ matrix.job.name }} - path: docker-classic/ - - - name: Make binaries executable - run: chmod -v a+x docker-classic/* - - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 - - - name: Log in to Docker Hub - if: github.event_name != 'pull_request' - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_PASSWORD }} - - - name: Log in to GHCR - if: github.event_name != 'pull_request' - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: rustdesk - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v4 - with: - images: registry.hub.docker.com/${{ secrets.DOCKER_IMAGE_CLASSIC }} - - - name: Get git tag - id: vars - run: | - T=${GITHUB_REF#refs/*/} - M=${T%%.*} - echo "GIT_TAG=$T" >> $GITHUB_ENV - echo "MAJOR_TAG=$M" >> $GITHUB_ENV - - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: "./docker-classic" - platforms: ${{ matrix.job.docker_platform }} - push: true - provenance: false - tags: | - ${{ secrets.DOCKER_IMAGE_CLASSIC }}:${{ env.LATEST_TAG }}-${{ matrix.job.name }} - ${{ secrets.DOCKER_IMAGE_CLASSIC }}:${{ env.GIT_TAG }}-${{ matrix.job.name }} - ${{ secrets.DOCKER_IMAGE_CLASSIC }}:${{ env.MAJOR_TAG }}-${{ matrix.job.name }} - ${{ env.GHCR_IMAGE_CLASSIC }}:${{ env.LATEST_TAG }}-${{ matrix.job.name }} - ${{ env.GHCR_IMAGE_CLASSIC }}:${{ env.GIT_TAG }}-${{ matrix.job.name }} - ${{ env.GHCR_IMAGE_CLASSIC }}:${{ env.MAJOR_TAG }}-${{ matrix.job.name }} - labels: ${{ steps.meta.outputs.labels }} - - # docker build and push of multiarch images - docker-manifest-classic: - - name: Docker manifest - needs: docker - runs-on: ubuntu-22.04 - - steps: - - - name: Log in to Docker Hub - if: github.event_name != 'pull_request' - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKER_HUB_USERNAME }} - password: ${{ secrets.DOCKER_HUB_PASSWORD }} - - - name: Get git tag - id: vars - run: | - T=${GITHUB_REF#refs/*/} - M=${T%%.*} - echo "GIT_TAG=$T" >> $GITHUB_ENV - echo "MAJOR_TAG=$M" >> $GITHUB_ENV - - - name: Log in to GHCR - if: github.event_name != 'pull_request' - uses: docker/login-action@v2 - with: - registry: ghcr.io - username: rustdesk - password: ${{ secrets.GITHUB_TOKEN }} - - # manifest for :1.2.3 tag - # this has to run only if invoked by a new tag - - name: Create and push manifest (:ve.rs.ion) - uses: Noelware/docker-manifest-action@0.4.3 - if: github.event_name != 'workflow_dispatch' - with: - base-image: ${{ secrets.DOCKER_IMAGE_CLASSIC }}:${{ env.GIT_TAG }} - extra-images: ${{ secrets.DOCKER_IMAGE_CLASSIC }}:${{ env.GIT_TAG }}-amd64,${{ secrets.DOCKER_IMAGE_CLASSIC }}:${{ env.GIT_TAG }}-arm64v8,${{ secrets.DOCKER_IMAGE_CLASSIC }}:${{ env.GIT_TAG }}-armv7 - push: true - - # manifest for :1 tag (major release) - - name: Create and push manifest (:major) - uses: Noelware/docker-manifest-action@0.4.3 - with: - base-image: ${{ secrets.DOCKER_IMAGE_CLASSIC }}:${{ env.MAJOR_TAG }} - extra-images: ${{ secrets.DOCKER_IMAGE_CLASSIC }}:${{ env.MAJOR_TAG }}-amd64,${{ secrets.DOCKER_IMAGE_CLASSIC }}:${{ env.MAJOR_TAG }}-arm64v8,${{ secrets.DOCKER_IMAGE_CLASSIC }}:${{ env.MAJOR_TAG }}-armv7 - push: true - - # manifest for :latest tag - - name: Create and push manifest (:latest) - uses: Noelware/docker-manifest-action@0.4.3 - with: - base-image: ${{ secrets.DOCKER_IMAGE_CLASSIC }}:${{ env.LATEST_TAG }} - extra-images: ${{ secrets.DOCKER_IMAGE_CLASSIC }}:${{ env.LATEST_TAG }}-amd64,${{ secrets.DOCKER_IMAGE_CLASSIC }}:${{ env.LATEST_TAG }}-arm64v8,${{ secrets.DOCKER_IMAGE_CLASSIC }}:${{ env.LATEST_TAG }}-armv7 - push: true - - # GHCR manifests - # manifest for :1.2.3 tag - - name: Create and push GHCR manifest (:ve.rs.ion) - uses: Noelware/docker-manifest-action@0.4.3 - if: github.event_name != 'workflow_dispatch' - with: - base-image: ${{ env.GHCR_IMAGE_CLASSIC }}:${{ env.GIT_TAG }} - extra-images: ${{ env.GHCR_IMAGE_CLASSIC }}:${{ env.GIT_TAG }}-amd64,${{ env.GHCR_IMAGE_CLASSIC }}:${{ env.GIT_TAG }}-arm64v8,${{ env.GHCR_IMAGE_CLASSIC }}:${{ env.GIT_TAG }}-armv7 - push: true - - # manifest for :1 tag (major release) - - name: Create and push GHCR manifest (:major) - uses: Noelware/docker-manifest-action@0.4.3 - with: - base-image: ${{ env.GHCR_IMAGE_CLASSIC }}:${{ env.MAJOR_TAG }} - extra-images: ${{ env.GHCR_IMAGE_CLASSIC }}:${{ env.MAJOR_TAG }}-amd64,${{ env.GHCR_IMAGE_CLASSIC }}:${{ env.MAJOR_TAG }}-arm64v8,${{ env.GHCR_IMAGE_CLASSIC }}:${{ env.MAJOR_TAG }}-armv7 - push: true - - # manifest for :latest tag - - name: Create and push GHCR manifest (:latest) - uses: Noelware/docker-manifest-action@0.4.3 - with: - base-image: ${{ env.GHCR_IMAGE_CLASSIC }}:${{ env.LATEST_TAG }} - extra-images: ${{ env.GHCR_IMAGE_CLASSIC }}:${{ env.LATEST_TAG }}-amd64,${{ env.GHCR_IMAGE_CLASSIC }}:${{ env.LATEST_TAG }}-arm64v8,${{ env.GHCR_IMAGE_CLASSIC }}:${{ env.LATEST_TAG }}-armv7 - push: true - - - deb-package: - - name: debian package - ${{ matrix.job.name }} - needs: build - runs-on: ubuntu-22.04 - strategy: - fail-fast: false - matrix: - job: - - { name: "amd64", debian_platform: "amd64", crossbuild_package: "" } - - { name: "arm64v8", debian_platform: "arm64", crossbuild_package: "crossbuild-essential-arm64" } - - { name: "armv7", debian_platform: "armhf", crossbuild_package: "crossbuild-essential-armhf" } - - { name: "i386", debian_platform: "i386", crossbuild_package: "crossbuild-essential-i386" } - - steps: - - - name: Checkout - uses: actions/checkout@v3 - with: - submodules: recursive - - - name: Set up QEMU - uses: docker/setup-qemu-action@v2 - - - name: Create packaging env - run: | - sudo apt update - DEBIAN_FRONTEND=noninteractive sudo apt install -y devscripts build-essential debhelper pkg-config ${{ matrix.job.crossbuild_package }} - mkdir -p debian-build/${{ matrix.job.name }}/bin - - - name: Download binaries - uses: actions/download-artifact@v4 - with: - name: binaries-linux-${{ matrix.job.name }} - path: debian-build/${{ matrix.job.name }}/bin - - - name: Build package for ${{ matrix.job.name }} arch - run: | - chmod -v a+x debian-build/${{ matrix.job.name }}/bin/* - cp -vr debian systemd debian-build/${{ matrix.job.name }}/ - cat debian/control.tpl | sed 's/{{ ARCH }}/${{ matrix.job.debian_platform }}/' > debian-build/${{ matrix.job.name }}/debian/control - cd debian-build/${{ matrix.job.name }}/ - debuild -i -us -uc -b -a${{ matrix.job.debian_platform }} - - - name: Create Release - uses: softprops/action-gh-release@v1 - with: - draft: true - files: | - debian-build/rustdesk-server-hbbr_*_${{ matrix.job.debian_platform }}.deb - debian-build/rustdesk-server-hbbs_*_${{ matrix.job.debian_platform }}.deb - debian-build/rustdesk-server-utils_*_${{ matrix.job.debian_platform }}.deb diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 000000000..d8dec770e --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,68 @@ +name: ci + +permissions: + contents: read + +on: + pull_request: + push: + branches: + - master + +env: + CARGO_TERM_COLOR: always + RUST_TOOLCHAIN: "1.90" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + rust: + name: Rust checks + runs-on: ubuntu-22.04 + + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Install toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + + - name: Check Rust workspace + run: cargo check --workspace --all-features + + docker: + name: Docker build checks + runs-on: ubuntu-22.04 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Prepare Docker smoke-test binaries + run: | + touch docker/rootfs/usr/bin/hbbs docker/rootfs/usr/bin/hbbr docker/rootfs/usr/bin/rustdesk-utils + chmod +x docker/rootfs/usr/bin/hbbs docker/rootfs/usr/bin/hbbr docker/rootfs/usr/bin/rustdesk-utils + touch docker-classic/hbbs docker-classic/hbbr + chmod +x docker-classic/hbbs docker-classic/hbbr + + - name: Check S6 Docker image + uses: docker/build-push-action@v7 + with: + context: ./docker + push: false + provenance: false + build-args: | + S6_ARCH=x86_64 + + - name: Check classic Docker image + uses: docker/build-push-action@v7 + with: + context: ./docker-classic + push: false + provenance: false diff --git a/.github/workflows/debian.yaml b/.github/workflows/debian.yaml new file mode 100644 index 000000000..d83370588 --- /dev/null +++ b/.github/workflows/debian.yaml @@ -0,0 +1,152 @@ +name: debian + +permissions: + contents: write + +on: + workflow_dispatch: + inputs: + version: + description: Release tag to build, for example v1.2.3. + required: true + type: string + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+-[0-9]+" + +env: + CARGO_TERM_COLOR: always + RUST_TOOLCHAIN: "1.90" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + prepare: + name: Prepare release metadata + runs-on: ubuntu-22.04 + outputs: + release-tag: ${{ steps.version.outputs.release-tag }} + + steps: + - name: Resolve release version + id: version + shell: bash + run: | + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + tag="${{ inputs.version }}" + else + tag="${GITHUB_REF_NAME}" + fi + + if [[ ! "${tag}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then + echo "Invalid release tag: ${tag}" >&2 + exit 1 + fi + + echo "release-tag=${tag}" >> "${GITHUB_OUTPUT}" + + build-linux: + name: Build Linux - ${{ matrix.job.name }} + needs: prepare + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + job: + - { name: "amd64", target: "x86_64-unknown-linux-musl" } + - { name: "arm64v8", target: "aarch64-unknown-linux-musl" } + - { name: "armv7", target: "armv7-unknown-linux-musleabihf" } + - { name: "i386", target: "i686-unknown-linux-musl" } + + steps: + - name: Checkout workflow + uses: actions/checkout@v6 + + - name: Checkout release source + uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare.outputs.release-tag }} + path: source + submodules: recursive + + - name: Build Rust binaries + uses: ./.github/actions/build-rust-target + with: + target: ${{ matrix.job.target }} + toolchain: ${{ env.RUST_TOOLCHAIN }} + use-cross: "true" + working-directory: source + + - name: Make binaries executable + run: chmod -v a+x source/target/${{ matrix.job.target }}/release/* + + - name: Upload binaries + uses: actions/upload-artifact@v6 + with: + name: binaries-linux-${{ matrix.job.name }} + path: | + source/target/${{ matrix.job.target }}/release/hbbr + source/target/${{ matrix.job.target }}/release/hbbs + source/target/${{ matrix.job.target }}/release/rustdesk-utils + if-no-files-found: error + + deb-package: + name: Debian package - ${{ matrix.job.name }} + needs: + - prepare + - build-linux + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + job: + - { name: "amd64", debian_platform: "amd64", crossbuild_package: "" } + - { name: "arm64v8", debian_platform: "arm64", crossbuild_package: "crossbuild-essential-arm64" } + - { name: "armv7", debian_platform: "armhf", crossbuild_package: "crossbuild-essential-armhf" } + - { name: "i386", debian_platform: "i386", crossbuild_package: "crossbuild-essential-i386" } + + steps: + - name: Checkout workflow + uses: actions/checkout@v6 + + - name: Checkout release source + uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare.outputs.release-tag }} + path: source + submodules: recursive + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Create packaging env + run: | + sudo apt update + DEBIAN_FRONTEND=noninteractive sudo apt install -y devscripts build-essential debhelper pkg-config ${{ matrix.job.crossbuild_package }} + mkdir -p source/debian-build/${{ matrix.job.name }}/bin + + - name: Download binaries + uses: actions/download-artifact@v6 + with: + name: binaries-linux-${{ matrix.job.name }} + path: source/debian-build/${{ matrix.job.name }}/bin + + - name: Build package + run: | + chmod -v a+x source/debian-build/${{ matrix.job.name }}/bin/* + cp -vr source/debian source/systemd source/debian-build/${{ matrix.job.name }}/ + sed 's/{{ ARCH }}/${{ matrix.job.debian_platform }}/' source/debian/control.tpl > source/debian-build/${{ matrix.job.name }}/debian/control + cd source/debian-build/${{ matrix.job.name }}/ + debuild -i -us -uc -b -a${{ matrix.job.debian_platform }} + + - name: Create draft release + uses: softprops/action-gh-release@v3 + with: + draft: true + tag_name: ${{ needs.prepare.outputs.release-tag }} + files: | + source/debian-build/rustdesk-server-hbbr_*_${{ matrix.job.debian_platform }}.deb + source/debian-build/rustdesk-server-hbbs_*_${{ matrix.job.debian_platform }}.deb + source/debian-build/rustdesk-server-utils_*_${{ matrix.job.debian_platform }}.deb diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml new file mode 100644 index 000000000..57dcd74b6 --- /dev/null +++ b/.github/workflows/docker.yaml @@ -0,0 +1,310 @@ +name: docker + +# Required repository secrets: +# DOCKER_HUB_USERNAME is the Docker Hub username. +# DOCKER_HUB_PASSWORD is a Docker Hub token with read/write access. + +permissions: + contents: read + packages: write + +on: + workflow_dispatch: + inputs: + version: + description: Release tag to build, for example v1.2.3. + required: true + type: string + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+-[0-9]+" + +env: + CARGO_TERM_COLOR: always + LATEST_TAG: latest + RUST_TOOLCHAIN: "1.90" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + PROJECT_URL: https://github.com/${{ github.repository }} + DOCUMENTATION_URL: https://github.com/${{ github.repository }}#installation + DOCKER_IMAGE: hangyvv/rustdesk-server-s6 + DOCKER_IMAGE_CLASSIC: hangyvv/rustdesk-server + GHCR_IMAGE: ghcr.io/hangyvv/rustdesk-server-s6 + GHCR_IMAGE_CLASSIC: ghcr.io/hangyvv/rustdesk-server + +jobs: + prepare: + name: Prepare release metadata + runs-on: ubuntu-22.04 + outputs: + release-tag: ${{ steps.version.outputs.release-tag }} + major-tag: ${{ steps.version.outputs.major-tag }} + + steps: + - name: Resolve release version + id: version + shell: bash + run: | + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + tag="${{ inputs.version }}" + else + tag="${GITHUB_REF_NAME}" + fi + + if [[ ! "${tag}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then + echo "Invalid release tag: ${tag}" >&2 + exit 1 + fi + + normalized="${tag#v}" + major="${normalized%%.*}" + + echo "release-tag=${tag}" >> "${GITHUB_OUTPUT}" + echo "major-tag=${major}" >> "${GITHUB_OUTPUT}" + + build-linux: + name: Build Linux - ${{ matrix.job.name }} + needs: prepare + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + job: + - { name: "amd64", target: "x86_64-unknown-linux-musl" } + - { name: "arm64v8", target: "aarch64-unknown-linux-musl" } + - { name: "armv7", target: "armv7-unknown-linux-musleabihf" } + - { name: "i386", target: "i686-unknown-linux-musl" } + + steps: + - name: Checkout workflow + uses: actions/checkout@v6 + + - name: Checkout release source + uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare.outputs.release-tag }} + path: source + submodules: recursive + + - name: Build Rust binaries + uses: ./.github/actions/build-rust-target + with: + target: ${{ matrix.job.target }} + toolchain: ${{ env.RUST_TOOLCHAIN }} + use-cross: "true" + working-directory: source + + - name: Make binaries executable + run: chmod -v a+x source/target/${{ matrix.job.target }}/release/* + + - name: Upload binaries + uses: actions/upload-artifact@v6 + with: + name: binaries-linux-${{ matrix.job.name }} + path: | + source/target/${{ matrix.job.target }}/release/hbbr + source/target/${{ matrix.job.target }}/release/hbbs + source/target/${{ matrix.job.target }}/release/rustdesk-utils + if-no-files-found: error + + docker: + name: Docker S6 - ${{ matrix.job.name }} + needs: + - prepare + - build-linux + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + job: + - { name: "amd64", docker_platform: "linux/amd64", s6_platform: "x86_64" } + - { name: "arm64v8", docker_platform: "linux/arm64", s6_platform: "aarch64" } + - { name: "armv7", docker_platform: "linux/arm/v7", s6_platform: "armhf" } + - { name: "i386", docker_platform: "linux/386", s6_platform: "i686" } + + steps: + - name: Checkout workflow + uses: actions/checkout@v6 + + - name: Checkout release source + uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare.outputs.release-tag }} + path: source + submodules: recursive + + - name: Build and push Docker image + uses: ./.github/actions/docker-build-and-push + with: + artifact-name: binaries-linux-${{ matrix.job.name }} + artifact-path: source/docker/rootfs/usr/bin + context: ./source/docker + docker-platform: ${{ matrix.job.docker_platform }} + docker-hub-image: ${{ env.DOCKER_IMAGE }} + ghcr-image: ${{ env.GHCR_IMAGE }} + image-title: RustDesk Server S6 + image-description: Self-hosted RustDesk container image with s6-overlay and supervised services. + project-url: ${{ env.PROJECT_URL }} + documentation-url: ${{ env.DOCUMENTATION_URL }} + arch-name: ${{ matrix.job.name }} + s6-arch: ${{ matrix.job.s6_platform }} + version-tag: ${{ needs.prepare.outputs.release-tag }} + major-tag: ${{ needs.prepare.outputs.major-tag }} + latest-tag: ${{ env.LATEST_TAG }} + docker-hub-username: ${{ secrets.DOCKER_HUB_USERNAME }} + docker-hub-password: ${{ secrets.DOCKER_HUB_PASSWORD }} + ghcr-username: ${{ github.repository_owner }} + ghcr-token: ${{ secrets.GITHUB_TOKEN }} + + docker-classic: + name: Docker classic - ${{ matrix.job.name }} + needs: + - prepare + - build-linux + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + job: + - { name: "amd64", docker_platform: "linux/amd64" } + - { name: "arm64v8", docker_platform: "linux/arm64" } + - { name: "armv7", docker_platform: "linux/arm/v7" } + + steps: + - name: Checkout workflow + uses: actions/checkout@v6 + + - name: Checkout release source + uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare.outputs.release-tag }} + path: source + submodules: recursive + + - name: Build and push Docker image + uses: ./.github/actions/docker-build-and-push + with: + artifact-name: binaries-linux-${{ matrix.job.name }} + artifact-path: source/docker-classic + context: ./source/docker-classic + docker-platform: ${{ matrix.job.docker_platform }} + docker-hub-image: ${{ env.DOCKER_IMAGE_CLASSIC }} + ghcr-image: ${{ env.GHCR_IMAGE_CLASSIC }} + image-title: RustDesk Server + image-description: Self-hosted RustDesk container image for hbbs and hbbr. + project-url: ${{ env.PROJECT_URL }} + documentation-url: ${{ env.DOCUMENTATION_URL }} + arch-name: ${{ matrix.job.name }} + version-tag: ${{ needs.prepare.outputs.release-tag }} + major-tag: ${{ needs.prepare.outputs.major-tag }} + latest-tag: ${{ env.LATEST_TAG }} + docker-hub-username: ${{ secrets.DOCKER_HUB_USERNAME }} + docker-hub-password: ${{ secrets.DOCKER_HUB_PASSWORD }} + ghcr-username: ${{ github.repository_owner }} + ghcr-token: ${{ secrets.GITHUB_TOKEN }} + + docker-manifest: + name: Docker manifest - s6 + needs: + - prepare + - docker + runs-on: ubuntu-22.04 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Checkout release source + uses: actions/checkout@v6 + with: + path: source + + - name: Create Docker manifests + uses: ./.github/actions/docker-manifest + with: + docker-hub-image: ${{ env.DOCKER_IMAGE }} + ghcr-image: ${{ env.GHCR_IMAGE }} + source-path: source + image-title: RustDesk Server S6 + image-description: Self-hosted RustDesk container image with s6-overlay and supervised services. + project-url: ${{ env.PROJECT_URL }} + documentation-url: ${{ env.DOCUMENTATION_URL }} + arch-suffixes: amd64 arm64v8 armv7 i386 + version-tag: ${{ needs.prepare.outputs.release-tag }} + major-tag: ${{ needs.prepare.outputs.major-tag }} + latest-tag: ${{ env.LATEST_TAG }} + docker-hub-username: ${{ secrets.DOCKER_HUB_USERNAME }} + docker-hub-password: ${{ secrets.DOCKER_HUB_PASSWORD }} + ghcr-username: ${{ github.repository_owner }} + ghcr-token: ${{ secrets.GITHUB_TOKEN }} + + docker-manifest-classic: + name: Docker manifest - classic + needs: + - prepare + - docker-classic + runs-on: ubuntu-22.04 + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Checkout release source + uses: actions/checkout@v6 + with: + path: source + + - name: Create Docker manifests + uses: ./.github/actions/docker-manifest + with: + docker-hub-image: ${{ env.DOCKER_IMAGE_CLASSIC }} + ghcr-image: ${{ env.GHCR_IMAGE_CLASSIC }} + source-path: source + image-title: RustDesk Server + image-description: Self-hosted RustDesk container image for hbbs and hbbr. + project-url: ${{ env.PROJECT_URL }} + documentation-url: ${{ env.DOCUMENTATION_URL }} + arch-suffixes: amd64 arm64v8 armv7 + version-tag: ${{ needs.prepare.outputs.release-tag }} + major-tag: ${{ needs.prepare.outputs.major-tag }} + latest-tag: ${{ env.LATEST_TAG }} + docker-hub-username: ${{ secrets.DOCKER_HUB_USERNAME }} + docker-hub-password: ${{ secrets.DOCKER_HUB_PASSWORD }} + ghcr-username: ${{ github.repository_owner }} + ghcr-token: ${{ secrets.GITHUB_TOKEN }} + + dockerhub-description: + name: Docker Hub description + needs: + - prepare + - docker-manifest + - docker-manifest-classic + runs-on: ubuntu-22.04 + + steps: + - name: Checkout release source + uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare.outputs.release-tag }} + path: source + submodules: recursive + + - name: Update Docker Hub description - S6 + uses: peter-evans/dockerhub-description@v5 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_PASSWORD }} + repository: ${{ env.DOCKER_IMAGE }} + short-description: Self-hosted RustDesk container image with s6-overlay. + readme-filepath: ./source/docker/README.md + + - name: Update Docker Hub description - Classic + uses: peter-evans/dockerhub-description@v5 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_PASSWORD }} + repository: ${{ env.DOCKER_IMAGE_CLASSIC }} + short-description: Self-hosted RustDesk container image for hbbs and hbbr. + readme-filepath: ./source/docker-classic/README.md diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 000000000..e7dade6a9 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,262 @@ +name: release + +permissions: + contents: write + +on: + workflow_dispatch: + inputs: + version: + description: Release tag to build, for example v1.2.3. + required: true + type: string + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+-[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+-[0-9]+" + +env: + CARGO_TERM_COLOR: always + RUST_TOOLCHAIN: "1.90" + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + prepare: + name: Prepare release metadata + runs-on: ubuntu-22.04 + outputs: + release-tag: ${{ steps.version.outputs.release-tag }} + + steps: + - name: Resolve release version + id: version + shell: bash + run: | + if [ "${GITHUB_EVENT_NAME}" = "workflow_dispatch" ]; then + tag="${{ inputs.version }}" + else + tag="${GITHUB_REF_NAME}" + fi + + if [[ ! "${tag}" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then + echo "Invalid release tag: ${tag}" >&2 + exit 1 + fi + + echo "release-tag=${tag}" >> "${GITHUB_OUTPUT}" + + build-linux: + name: Build Linux - ${{ matrix.job.name }} + needs: prepare + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + job: + - { name: "amd64", target: "x86_64-unknown-linux-musl" } + - { name: "arm64v8", target: "aarch64-unknown-linux-musl" } + - { name: "armv7", target: "armv7-unknown-linux-musleabihf" } + - { name: "i386", target: "i686-unknown-linux-musl" } + + steps: + - name: Checkout workflow + uses: actions/checkout@v6 + + - name: Checkout release source + uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare.outputs.release-tag }} + path: source + submodules: recursive + + - name: Build Rust binaries + uses: ./.github/actions/build-rust-target + with: + target: ${{ matrix.job.target }} + toolchain: ${{ env.RUST_TOOLCHAIN }} + use-cross: "true" + working-directory: source + + - name: Make binaries executable + run: chmod -v a+x source/target/${{ matrix.job.target }}/release/* + + - name: Upload binaries + uses: actions/upload-artifact@v6 + with: + name: binaries-linux-${{ matrix.job.name }} + path: | + source/target/${{ matrix.job.target }}/release/hbbr + source/target/${{ matrix.job.target }}/release/hbbs + source/target/${{ matrix.job.target }}/release/rustdesk-utils + if-no-files-found: error + + build-windows: + name: Build Windows - x86_64 + needs: prepare + runs-on: windows-2022 + env: + WINDOWS_TARGET: x86_64-pc-windows-msvc + + steps: + - name: Checkout workflow + uses: actions/checkout@v6 + + - name: Checkout release source + uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare.outputs.release-tag }} + path: source + submodules: recursive + + - name: Build Rust binaries + uses: ./.github/actions/build-rust-target + with: + target: ${{ env.WINDOWS_TARGET }} + toolchain: ${{ env.RUST_TOOLCHAIN }} + use-cross: "false" + working-directory: source + + - name: Install NSIS + shell: pwsh + run: | + iwr -useb get.scoop.sh -outfile install.ps1 + .\install.ps1 -RunAsAdmin + scoop update + scoop bucket add extras + scoop install nsis + + - name: Install Node.js + uses: actions/setup-node@v6 + with: + node-version: 16 + + - name: Build UI browser file + working-directory: ./source/ui/html + shell: pwsh + run: | + npm i + npm run build + + - name: Build UI setup file + working-directory: ./source/ui + shell: pwsh + run: | + rustup default nightly + cargo build --release + xcopy /y ..\target\x86_64-pc-windows-msvc\release\*.exe setup\bin\ + xcopy /y target\release\*.exe setup\ + mkdir setup\logs + makensis /V1 setup.nsi + mkdir SignOutput + mv RustDeskServer.Setup.exe SignOutput\ + mv ..\target\x86_64-pc-windows-msvc\release\*.exe SignOutput\ + + - name: Upload binaries + uses: actions/upload-artifact@v6 + with: + name: binaries-windows-x86_64 + path: | + source\ui\SignOutput\hbbr.exe + source\ui\SignOutput\hbbs.exe + source\ui\SignOutput\rustdesk-utils.exe + source\ui\SignOutput\RustDeskServer.Setup.exe + if-no-files-found: error + + release-notes: + name: Generate release notes + needs: prepare + runs-on: ubuntu-22.04 + + steps: + - name: Checkout release source + uses: actions/checkout@v6 + with: + ref: ${{ needs.prepare.outputs.release-tag }} + fetch-depth: 0 + submodules: recursive + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + + - name: Generate release notes + run: | + cargo run --locked --quiet --bin release-notes -- \ + --tag "${{ needs.prepare.outputs.release-tag }}" \ + --repo-url "https://github.com/${{ github.repository }}" \ + --cwd "${GITHUB_WORKSPACE}" \ + --output release-notes.md \ + --release-date "$(date -u +%F)" + + - name: Upload release notes + uses: actions/upload-artifact@v6 + with: + name: release-notes + path: release-notes.md + if-no-files-found: error + + create-release: + name: Create GitHub draft release + needs: + - prepare + - release-notes + runs-on: ubuntu-22.04 + + steps: + - name: Download release notes + uses: actions/download-artifact@v6 + with: + name: release-notes + path: . + + - name: Create draft release + uses: softprops/action-gh-release@v3 + with: + draft: true + tag_name: ${{ needs.prepare.outputs.release-tag }} + body_path: release-notes.md + + release: + name: GitHub release - ${{ matrix.job.os }} ${{ matrix.job.name }} + needs: + - prepare + - build-linux + - build-windows + - create-release + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + job: + - { os: "linux", name: "amd64", suffix: "" } + - { os: "linux", name: "arm64v8", suffix: "" } + - { os: "linux", name: "armv7", suffix: "" } + - { os: "linux", name: "i386", suffix: "" } + - { os: "windows", name: "x86_64", suffix: "-unsigned" } + + steps: + - name: Download binaries + uses: actions/download-artifact@v6 + with: + name: binaries-${{ matrix.job.os }}-${{ matrix.job.name }} + path: ${{ matrix.job.name }} + + - name: Pack files + run: | + sudo apt update + DEBIAN_FRONTEND=noninteractive sudo apt install -y zip + chmod -v a+x ${{ matrix.job.name }}/* + zip ${{ matrix.job.name }}/rustdesk-server-${{ matrix.job.os }}-${{ matrix.job.name }}${{ matrix.job.suffix }}.zip ${{ matrix.job.name }}/* + + - name: Upload release asset + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release upload "${{ needs.prepare.outputs.release-tag }}" \ + "${{ matrix.job.name }}/rustdesk-server-${{ matrix.job.os }}-${{ matrix.job.name }}${{ matrix.job.suffix }}.zip" \ + --clobber \ + --repo "${{ github.repository }}" diff --git a/Cargo.toml b/Cargo.toml index 43963d125..587965e30 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,10 @@ path = "src/hbbr.rs" name = "rustdesk-utils" path = "src/utils.rs" +[[bin]] +name = "release-notes" +path = "src/bin/release-notes.rs" + # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] diff --git a/src/bin/release-notes.rs b/src/bin/release-notes.rs new file mode 100644 index 000000000..6ca21b682 --- /dev/null +++ b/src/bin/release-notes.rs @@ -0,0 +1,466 @@ +use std::collections::HashMap; +use std::env; +use std::error::Error; +use std::ffi::OsString; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use chrono::Utc; + +const SECTION_ORDER: [&str; 6] = [ + "Added", + "Changed", + "Deprecated", + "Removed", + "Fixed", + "Security", +]; + +#[derive(Debug)] +struct CliError(String); + +impl fmt::Display for CliError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl Error for CliError {} + +#[derive(Debug)] +struct ReleaseNotesArgs { + tag: String, + repo_url: String, + cwd: PathBuf, + output: PathBuf, + release_date: String, +} + +fn main() -> Result<(), Box> { + let args = parse_args(env::args_os().skip(1))?; + let notes = build_release_notes(&args.cwd, &args.tag, &args.repo_url, &args.release_date)?; + + if args.output == Path::new("-") { + print!("{notes}"); + println!(); + } else { + fs::write(&args.output, format!("{notes}\n"))?; + } + + Ok(()) +} + +fn parse_args(mut args: I) -> Result> +where + I: Iterator, +{ + let mut tag = None; + let mut repo_url = None; + let mut cwd = env::current_dir()?; + let mut output = PathBuf::from("-"); + let mut release_date = today_utc(); + + while let Some(arg) = args.next() { + let key = arg + .into_string() + .map_err(|_| CliError("Arguments must be valid UTF-8".into()))?; + + match key.as_str() { + "--tag" => tag = Some(next_string(&mut args, "--tag")?), + "--repo-url" => repo_url = Some(next_string(&mut args, "--repo-url")?), + "--cwd" => cwd = PathBuf::from(next_string(&mut args, "--cwd")?), + "--output" => output = PathBuf::from(next_string(&mut args, "--output")?), + "--release-date" => release_date = next_string(&mut args, "--release-date")?, + "-h" | "--help" => { + print_help(); + std::process::exit(0); + } + other => return Err(Box::new(CliError(format!("Unknown argument: {other}")))), + } + } + + let tag = tag.ok_or_else(|| CliError("Missing required argument: --tag".into()))?; + let repo_url = + repo_url.ok_or_else(|| CliError("Missing required argument: --repo-url".into()))?; + + Ok(ReleaseNotesArgs { + tag, + repo_url, + cwd, + output, + release_date, + }) +} + +fn next_string(args: &mut I, key: &str) -> Result> +where + I: Iterator, +{ + let value = args + .next() + .ok_or_else(|| CliError(format!("Missing value for {key}")))?; + Ok(value + .into_string() + .map_err(|_| CliError(format!("Value for {key} must be valid UTF-8")))?) +} + +fn print_help() { + println!( + "usage: release-notes --tag TAG --repo-url URL [--cwd DIR] [--output FILE] [--release-date YYYY-MM-DD]" + ); +} + +fn today_utc() -> String { + Utc::now().date_naive().format("%F").to_string() +} + +fn build_release_notes( + cwd: &Path, + tag: &str, + repo_url: &str, + release_date: &str, +) -> Result> { + let previous_tag = resolve_previous_tag(cwd, tag)?; + let commits = resolve_commit_subjects(cwd, tag, previous_tag.as_deref())?; + Ok(render_release_notes( + tag, + previous_tag.as_deref(), + release_date, + repo_url, + &commits, + )) +} + +fn resolve_previous_tag(cwd: &Path, current_tag: &str) -> Result, Box> { + let output = git(cwd, ["tag", "--sort=-creatordate"])?; + for line in output.lines() { + let tag = line.trim(); + if !tag.is_empty() && tag != current_tag { + return Ok(Some(tag.to_string())); + } + } + Ok(None) +} + +fn resolve_commit_subjects( + cwd: &Path, + tag: &str, + previous_tag: Option<&str>, +) -> Result, Box> { + let Some(previous_tag) = previous_tag else { + let output = git(cwd, ["log", "--no-merges", "--format=%s", tag])?; + return Ok(output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect()); + }; + + let range = format!("{previous_tag}...{tag}"); + let output = git( + cwd, + [ + "log", + "--left-right", + "--no-merges", + "--format=%m%x00%ae%x00%aI%x00%s", + &range, + ], + )?; + deduplicate_rebased_commits(&output) +} + +fn deduplicate_rebased_commits(output: &str) -> Result, Box> { + let mut previous = HashMap::::new(); + let mut current = Vec::new(); + + for line in output.lines().filter(|line| !line.is_empty()) { + let mut fields = line.splitn(4, '\0'); + let side = fields.next(); + let email = fields.next(); + let authored_at = fields.next(); + let subject = fields.next(); + let (Some(side), Some(email), Some(authored_at), Some(subject)) = + (side, email, authored_at, subject) + else { + return Err(Box::new(CliError( + "Unexpected git log output while generating release notes".into(), + ))); + }; + // ponytail: assumes rebase preserves author metadata and subject; + // use Change-Id trailers if that stops holding. + let identity = format!("{email}\0{authored_at}\0{subject}"); + match side { + "<" => *previous.entry(identity).or_default() += 1, + ">" => current.push((identity, subject.to_string())), + _ => { + return Err(Box::new(CliError( + "Unexpected git history side while generating release notes".into(), + ))) + } + } + } + + Ok(current + .into_iter() + .filter_map(|(identity, subject)| match previous.get_mut(&identity) { + Some(count) if *count > 0 => { + *count -= 1; + None + } + _ => Some(subject), + }) + .collect()) +} + +fn git(cwd: &Path, args: [&str; N]) -> Result> { + let output = Command::new("git").args(args).current_dir(cwd).output()?; + if !output.status.success() { + return Err(Box::new(CliError(format!( + "git command failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )))); + } + Ok(String::from_utf8(output.stdout)?.trim().to_string()) +} + +fn render_release_notes( + tag: &str, + previous_tag: Option<&str>, + release_date: &str, + repo_url: &str, + commits: &[String], +) -> String { + let mut sections: [Vec; 6] = Default::default(); + + for subject in commits { + let section = classify_subject(subject); + let cleaned = normalize_subject(subject); + let index = section_index(section); + if !sections[index].contains(&cleaned) { + sections[index].push(cleaned); + } + } + + let mut lines = vec![ + "# Changelog".to_string(), + String::new(), + format!("## [{}] - {}", tag.trim_start_matches('v'), release_date), + String::new(), + ]; + + for (index, section_name) in SECTION_ORDER.iter().enumerate() { + let items = §ions[index]; + if items.is_empty() { + continue; + } + + lines.push(format!("### {section_name}")); + lines.push(String::new()); + for item in items { + lines.push(format!("- {item}")); + } + lines.push(String::new()); + } + + if let Some(previous_tag) = previous_tag { + lines.extend([ + "### Full Changelog".to_string(), + String::new(), + "
".to_string(), + "Show full changelog".to_string(), + String::new(), + format!( + "[Compare changes]({}/compare/{}...{})", + repo_url.trim_end_matches('/'), + previous_tag, + tag + ), + String::new(), + "
".to_string(), + ]); + } else { + lines.pop(); + } + + lines.join("\n") +} + +fn section_index(section: &'static str) -> usize { + match section { + "Added" => 0, + "Changed" => 1, + "Deprecated" => 2, + "Removed" => 3, + "Fixed" => 4, + "Security" => 5, + _ => 1, + } +} + +fn normalize_subject(subject: &str) -> String { + let subject = strip_pr_suffix(subject); + let subject = strip_conventional_prefix(&subject); + strip_leading_action(&subject) +} + +fn strip_pr_suffix(subject: &str) -> String { + let mut trimmed = subject.trim().to_string(); + if let Some(start) = trimmed.rfind(" (#") { + if trimmed.ends_with(')') + && trimmed[start + 3..trimmed.len() - 1] + .chars() + .all(|c| c.is_ascii_digit()) + { + trimmed.truncate(start); + trimmed = trimmed.trim().to_string(); + } + } + trimmed +} + +fn strip_conventional_prefix(subject: &str) -> String { + let Some(colon) = subject.find(':') else { + return subject.trim().to_string(); + }; + + let prefix = &subject[..colon]; + if prefix + .chars() + .all(|c| c.is_ascii_alphabetic() || c == '(' || c == ')') + { + return subject[colon + 1..].trim().to_string(); + } + + subject.trim().to_string() +} + +fn strip_leading_action(subject: &str) -> String { + for prefix in [ + "add ", + "added ", + "fix ", + "fixed ", + "update ", + "updated ", + "remove ", + "removed ", + "delete ", + "deleted ", + ] { + if subject.len() >= prefix.len() && subject[..prefix.len()].eq_ignore_ascii_case(prefix) { + return subject[prefix.len()..].trim().to_string(); + } + } + subject.trim().to_string() +} + +fn classify_subject(subject: &str) -> &'static str { + let lower = subject.to_ascii_lowercase(); + if lower.contains("security") { + "Security" + } else if lower.starts_with("feat") || lower.starts_with("add") { + "Added" + } else if lower.starts_with("fix") || lower.starts_with("bugfix") { + "Fixed" + } else if lower.starts_with("remove") || lower.starts_with("delete") { + "Removed" + } else if lower.starts_with("deprecate") { + "Deprecated" + } else { + "Changed" + } +} + +#[cfg(test)] +mod tests { + use super::{deduplicate_rebased_commits, normalize_subject, render_release_notes}; + + #[test] + fn excludes_commits_replayed_by_rebase() { + let commits = deduplicate_rebased_commits(concat!( + "<\0dev@example.com\02026-06-01T10:00:00Z\0feat: login\n", + ">\0upstream@example.com\02026-07-01T10:00:00Z\0fix: upstream bug\n", + ">\0dev@example.com\02026-06-01T10:00:00Z\0feat: login\n", + )) + .unwrap(); + + assert_eq!(commits, ["fix: upstream bug"]); + } + + #[test] + fn renders_keep_a_changelog_sections() { + let notes = render_release_notes( + "v1.2.3", + Some("v1.2.2"), + "2026-06-01", + "https://github.com/example/repo", + &[ + "feat: add api login enforcement".to_string(), + "fix(security): update mio from 0.8.5 to 0.8.11 (#633)".to_string(), + "docs: update README.md".to_string(), + "refactor: simplify relay server flow".to_string(), + "remove stale debug logging".to_string(), + "fix: 127.0.0.1 is not loopback (#515)".to_string(), + ], + ); + + let expected = [ + "# Changelog", + "", + "## [1.2.3] - 2026-06-01", + "", + "### Added", + "", + "- api login enforcement", + "", + "### Changed", + "", + "- README.md", + "- simplify relay server flow", + "", + "### Removed", + "", + "- stale debug logging", + "", + "### Fixed", + "", + "- 127.0.0.1 is not loopback", + "", + "### Security", + "", + "- mio from 0.8.5 to 0.8.11", + "", + "### Full Changelog", + "", + "
", + "Show full changelog", + "", + "[Compare changes](https://github.com/example/repo/compare/v1.2.2...v1.2.3)", + "", + "
", + ] + .join("\n"); + + assert_eq!(notes, expected); + } + + #[test] + fn strips_conventional_prefixes_and_actions() { + assert_eq!( + normalize_subject("fix(security): update mio"), + "mio" + ); + assert_eq!( + normalize_subject("chore: Added kubernetes example file (#623)"), + "kubernetes example file" + ); + assert_eq!( + normalize_subject("remove stale debug logging"), + "stale debug logging" + ); + } +} From 80948f6dc8dcc472ade0090ba860ec19942b12b6 Mon Sep 17 00:00:00 2001 From: HanGYvv Date: Fri, 12 Jun 2026 12:55:41 +0800 Subject: [PATCH 2/7] docs: add Docker Hub READMEs and refresh badges Add Docker Hub README files for the s6 and classic images, consumed by the docker workflow's description-sync step. Update the main README badges and links to point at the new workflows and repository. --- README.md | 11 +++-- docker-classic/README.md | 88 ++++++++++++++++++++++++++++++++++++++++ docker/README.md | 79 ++++++++++++++++++++++++++++++++++++ 3 files changed, 175 insertions(+), 3 deletions(-) create mode 100644 docker-classic/README.md create mode 100644 docker/README.md diff --git a/README.md b/README.md index 887cee604..74c0fe354 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,13 @@ # RustDesk Server Program -[![build](https://github.com/rustdesk/rustdesk-server/actions/workflows/build.yaml/badge.svg)](https://github.com/rustdesk/rustdesk-server/actions/workflows/build.yaml) +[![version](https://img.shields.io/github/v/tag/HanGYvv/rustdesk-server?label=version)](https://github.com/HanGYvv/rustdesk-server/releases) +[![license](https://img.shields.io/github/license/HanGYvv/rustdesk-server)](LICENSE) +[![ci](https://github.com/HanGYvv/rustdesk-server/actions/workflows/ci.yaml/badge.svg)](https://github.com/HanGYvv/rustdesk-server/actions/workflows/ci.yaml) +[![docker](https://github.com/HanGYvv/rustdesk-server/actions/workflows/docker.yaml/badge.svg)](https://github.com/HanGYvv/rustdesk-server/actions/workflows/docker.yaml) +[![release](https://github.com/HanGYvv/rustdesk-server/actions/workflows/release.yaml/badge.svg)](https://github.com/HanGYvv/rustdesk-server/actions/workflows/release.yaml) +[![debian](https://github.com/HanGYvv/rustdesk-server/actions/workflows/debian.yaml/badge.svg)](https://github.com/HanGYvv/rustdesk-server/actions/workflows/debian.yaml) -[**Download**](https://github.com/rustdesk/rustdesk-server/releases) +[**Download**](https://github.com/HanGYvv/rustdesk-server/releases) [**Manual**](https://rustdesk.com/docs/en/self-host/) @@ -31,7 +36,7 @@ Three executables will be generated in target/release. - hbbr - RustDesk relay server - rustdesk-utils - RustDesk CLI utilities -You can find updated binaries on the [Releases](https://github.com/rustdesk/rustdesk-server/releases) page. +You can find updated binaries on the [Releases](https://github.com/HanGYvv/rustdesk-server/releases) page. ## Configuration diff --git a/docker-classic/README.md b/docker-classic/README.md new file mode 100644 index 000000000..25e39effc --- /dev/null +++ b/docker-classic/README.md @@ -0,0 +1,88 @@ +# RustDesk Server + +RustDesk Server is a minimal self-hosted RustDesk container image. It bundles +the `hbbs` and `hbbr` binaries in a `scratch`-based runtime without +s6-overlay, leaving service supervision to your own platform or orchestrator. + +- **Source repository:** +- **Docker Hub:** `hangyvv/rustdesk-server` +- **GHCR:** `ghcr.io/hangyvv/rustdesk-server` + +> Looking for a single-container image with built-in supervision and health +> checks? Use the s6-overlay variant: `hangyvv/rustdesk-server-s6`. + +## Highlights + +- Self-hosted RustDesk server (`hbbs` + `hbbr`) +- Minimal `scratch`-based image containing only the server binaries +- No bundled supervisor: run `hbbs` and `hbbr` as you prefer + +## Exposed ports + +| Port | Service | Purpose | +| ----------- | ------- | ----------------------------- | +| `21115/tcp` | hbbs | NAT type test | +| `21116/tcp` | hbbs | TCP hole punching / heartbeat | +| `21116/udp` | hbbs | ID registration / heartbeat | +| `21117/tcp` | hbbr | Relay service | +| `21118/tcp` | hbbs | Web client support | +| `21119/tcp` | hbbr | Web client support | + +## Quick start + +This image has no entrypoint, so you provide the command to run. `hbbs` and +`hbbr` are separate processes and are typically run as two containers: + +```bash +# Relay server +docker run -d --name hbbr \ + -p 21117:21117 -p 21119:21119 \ + -v rustdesk-data:/root \ + hangyvv/rustdesk-server:latest hbbr + +# ID / rendezvous server +docker run -d --name hbbs \ + -p 21115:21115 -p 21116:21116 -p 21116:21116/udp -p 21118:21118 \ + -v rustdesk-data:/root \ + hangyvv/rustdesk-server:latest hbbs -r :21117 +``` + +### docker-compose + +```yaml +services: + hbbs: + image: hangyvv/rustdesk-server:latest + container_name: hbbs + command: hbbs -r :21117 + ports: + - "21115:21115" + - "21116:21116" + - "21116:21116/udp" + - "21118:21118" + volumes: + - rustdesk-data:/root + depends_on: + - hbbr + restart: unless-stopped + + hbbr: + image: hangyvv/rustdesk-server:latest + container_name: hbbr + command: hbbr + ports: + - "21117:21117" + - "21119:21119" + volumes: + - rustdesk-data:/root + restart: unless-stopped + +volumes: + rustdesk-data: +``` + +## Data & keys + +Server state, including the generated `id_ed25519` / `id_ed25519.pub` key +pair, is written to the working directory (`/root`). Mount a shared volume +there for both services and back it up to preserve your server identity. diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 000000000..68380655a --- /dev/null +++ b/docker/README.md @@ -0,0 +1,79 @@ +# RustDesk Server S6 + +RustDesk Server S6 is a self-hosted RustDesk container image built on +[s6-overlay](https://github.com/just-containers/s6-overlay). It bundles +`hbbs` and `hbbr` into a single compact runtime with proper service +supervision. + +- **Source repository:** +- **Docker Hub:** `hangyvv/rustdesk-server-s6` +- **GHCR:** `ghcr.io/hangyvv/rustdesk-server-s6` + +## Highlights + +- Self-hosted RustDesk server (`hbbs` + `hbbr`) in one image +- Built with s6-overlay for reliable service supervision and health checks +- Automatic key-pair handling via Docker secrets or environment variables +- Persistent data through a single `/data` volume + +## Exposed ports + +| Port | Service | Purpose | +| ----------- | ------- | -------------------------------- | +| `21115/tcp` | hbbs | NAT type test | +| `21116/tcp` | hbbs | TCP hole punching / heartbeat | +| `21116/udp` | hbbs | ID registration / heartbeat | +| `21117/tcp` | hbbr | Relay service | +| `21118/tcp` | hbbs | Web client support | +| `21119/tcp` | hbbr | Web client support | + +## Environment variables + +| Variable | Default | Description | +| ---------------- | --------------------- | -------------------------------------------------------- | +| `RELAY` | `relay.example.com` | Public address advertised for the relay (`hbbr`) | +| `ENCRYPTED_ONLY` | `0` | Set to `1` to force encrypted connections (`-k _`) | +| `KEY_PUB` | _(unset)_ | Public key content, written to `/data/id_ed25519.pub` | +| `KEY_PRIV` | _(unset)_ | Private key content, written to `/data/id_ed25519` | + +Keys can also be supplied as Docker secrets named `key_pub` and `key_priv`. +If no key pair is provided, `hbbs` generates one on first start. + +## Quick start + +```bash +docker run -d \ + --name rustdesk-server \ + -e RELAY=relay.example.com \ + -p 21115-21119:21115-21119 \ + -p 21116:21116/udp \ + -v rustdesk-data:/data \ + hangyvv/rustdesk-server-s6:latest +``` + +### docker-compose + +```yaml +services: + rustdesk-server: + image: hangyvv/rustdesk-server-s6:latest + container_name: rustdesk-server + environment: + - RELAY=relay.example.com + - ENCRYPTED_ONLY=0 + ports: + - "21115-21119:21115-21119" + - "21116:21116/udp" + volumes: + - rustdesk-data:/data + restart: unless-stopped + +volumes: + rustdesk-data: +``` + +## Data & keys + +All persistent state, including the generated `id_ed25519` / +`id_ed25519.pub` key pair, lives under the `/data` volume. Back this volume +up to preserve your server identity across container recreation. From f50bd52d52a8f86c71cbfd676303ccccd0f57b4a Mon Sep 17 00:00:00 2001 From: HanGYvv Date: Mon, 1 Jun 2026 16:55:46 +0800 Subject: [PATCH 3/7] feat(hbbs): add API login enforcement Ported and adapted from lejianwen/forapi: - MUST_LOGIN / JWT login enforcement - RegisterPk / OnlineRequest handling - WebSocket peer forwarding Adjusted for the current codebase and stricter JWT secret enforcement. --- src/jwt.rs | 27 ++++ src/lib.rs | 1 + src/main.rs | 3 +- src/rendezvous_server.rs | 271 +++++++++++++++++++++++++++++++++------ 4 files changed, 261 insertions(+), 41 deletions(-) create mode 100644 src/jwt.rs diff --git a/src/jwt.rs b/src/jwt.rs new file mode 100644 index 000000000..c1b05af65 --- /dev/null +++ b/src/jwt.rs @@ -0,0 +1,27 @@ +use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub user_id: u32, + pub exp: usize, +} + +pub fn secret() -> String { + std::env::var("RUSTDESK_API_JWT_KEY").unwrap_or_default() +} + +pub fn verify_token(token: &str) -> Result { + let secret = secret(); + if secret.is_empty() { + return Err("JWT secret is not configured".to_owned()); + } + + decode::( + token, + &DecodingKey::from_secret(secret.as_bytes()), + &Validation::new(Algorithm::HS256), + ) + .map(|token_data| token_data.claims) + .map_err(|_| "Invalid token".to_owned()) +} diff --git a/src/lib.rs b/src/lib.rs index 8da29a2d6..66873b09d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ mod rendezvous_server; pub use rendezvous_server::*; pub mod common; +pub mod jwt; mod database; mod peer; mod version; diff --git a/src/main.rs b/src/main.rs index d422c3c06..211355de4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,7 +23,8 @@ fn main() -> ResultType<()> { -r, --relay-servers=[HOST] 'Sets the default relay servers, separated by comma' -M, --rmem=[NUMBER(default={RMEM})] 'Sets UDP recv buffer size, set system rmem_max first, e.g., sudo sysctl -w net.core.rmem_max=52428800. vi /etc/sysctl.conf, net.core.rmem_max=52428800, sudo sysctl –p' , --mask=[MASK] '[DEPRECATED] Determine if the connection comes from LAN, e.g. 192.168.0.0/16' - -k, --key=[KEY] 'Only allow the client with the same key'", + -k, --key=[KEY] 'Only allow the client with the same key' + , --must-login=[Y|N] 'Only allow logged-in clients to connect'", ); init_args(&args, "hbbs", "RustDesk ID/Rendezvous Server"); let port = get_arg_or("port", RENDEZVOUS_PORT.to_string()).parse::()?; diff --git a/src/rendezvous_server.rs b/src/rendezvous_server.rs index eaf7190f9..a86ad12ea 100644 --- a/src/rendezvous_server.rs +++ b/src/rendezvous_server.rs @@ -1,4 +1,5 @@ use crate::common::*; +use crate::jwt; use crate::peer::*; use hbb_common::{ allow_err, bail, @@ -13,7 +14,7 @@ use hbb_common::{ log, protobuf::{Message as _, MessageField}, rendezvous_proto::{ - register_pk_response::Result::{TOO_FREQUENT, UUID_MISMATCH}, + register_pk_response::Result::{INVALID_ID_FORMAT, TOO_FREQUENT, UUID_MISMATCH}, *, }, tcp::FramedStream, @@ -54,12 +55,28 @@ enum Sink { TcpStream(TcpStreamSink), Ws(WsSink), } + +impl Sink { + async fn send(&mut self, msg: &RendezvousMessage) { + if let Ok(bytes) = msg.write_to_bytes() { + match self { + Sink::TcpStream(s) => { + allow_err!(s.send(Bytes::from(bytes)).await); + } + Sink::Ws(ws) => { + allow_err!(ws.send(tungstenite::Message::Binary(bytes)).await); + } + } + } + } +} type Sender = mpsc::UnboundedSender; type Receiver = mpsc::UnboundedReceiver; static ROTATION_RELAY_SERVER: AtomicUsize = AtomicUsize::new(0); type RelayServers = Vec; const CHECK_RELAY_TIMEOUT: u64 = 3_000; static ALWAYS_USE_RELAY: AtomicBool = AtomicBool::new(false); +static MUST_LOGIN: AtomicBool = AtomicBool::new(false); // Store punch hole requests use once_cell::sync::Lazy; @@ -88,6 +105,7 @@ pub struct RendezvousServer { relay_servers0: Arc, rendezvous_servers: Arc>, inner: Arc, + ws_map: Arc>>, } enum LoopFailure { @@ -150,6 +168,7 @@ impl RendezvousServer { mask, local_ip, }), + ws_map: Arc::new(Mutex::new(HashMap::new())), }; log::info!("mask: {:?}", rs.inner.mask); log::info!("local-ip: {:?}", rs.inner.local_ip); @@ -177,6 +196,24 @@ impl RendezvousServer { "N" } ); + let must_login = get_arg("must-login"); + if must_login.to_uppercase() == "Y" + || (must_login.is_empty() + && std::env::var("MUST_LOGIN") + .unwrap_or_default() + .to_uppercase() + == "Y") + { + MUST_LOGIN.store(true, Ordering::SeqCst); + } + log::info!( + "MUST_LOGIN={}", + if MUST_LOGIN.load(Ordering::SeqCst) { + "Y" + } else { + "N" + } + ); if test_addr.to_lowercase() != "no" { let test_addr = if test_addr.is_empty() { listener.local_addr()? @@ -356,7 +393,13 @@ impl RendezvousServer { // B registered if !rp.id.is_empty() { log::trace!("New peer registered: {:?} {:?}", &rp.id, &addr); - self.update_addr(rp.id, addr, socket).await?; + let request_pk = self.update_addr(rp.id, addr).await; + let mut msg_out = RendezvousMessage::new(); + msg_out.set_register_peer_response(RegisterPeerResponse { + request_pk, + ..Default::default() + }); + socket.send(&msg_out, addr).await?; if self.inner.serial > rp.serial { let mut msg_out = RendezvousMessage::new(); msg_out.set_configure_update(ConfigUpdate { @@ -511,6 +554,28 @@ impl RendezvousServer { ) -> bool { if let Ok(msg_in) = RendezvousMessage::parse_from_bytes(bytes) { match msg_in.union { + Some(rendezvous_message::Union::RegisterPeer(rp)) => { + if !rp.id.is_empty() { + log::trace!("New peer registered: {:?} {:?}", &rp.id, &addr); + let request_pk = self.update_addr(rp.id, addr).await; + let mut msg_out = RendezvousMessage::new(); + msg_out.set_register_peer_response(RegisterPeerResponse { + request_pk, + ..Default::default() + }); + Self::send_to_sink(sink, msg_out).await; + if self.inner.serial > rp.serial { + let mut msg_out = RendezvousMessage::new(); + msg_out.set_configure_update(ConfigUpdate { + serial: self.inner.serial, + rendezvous_servers: (*self.rendezvous_servers).clone(), + ..Default::default() + }); + Self::send_to_sink(sink, msg_out).await; + } + } + return true; + } Some(rendezvous_message::Union::PunchHoleRequest(ph)) => { // there maybe several attempt, so sink can be none if let Some(sink) = sink.take() { @@ -574,14 +639,32 @@ impl RendezvousServer { msg_out.set_test_nat_response(res); Self::send_to_sink(sink, msg_out).await; } - Some(rendezvous_message::Union::RegisterPk(_)) => { - let res = register_pk_response::Result::NOT_SUPPORT; + Some(rendezvous_message::Union::RegisterPk(rk)) => { + let res = match self.handle_register_pk(rk, addr, ws).await { + Ok(res) | Err(res) => res, + }; let mut msg_out = RendezvousMessage::new(); msg_out.set_register_pk_response(RegisterPkResponse { result: res.into(), ..Default::default() }); Self::send_to_sink(sink, msg_out).await; + if res == register_pk_response::Result::OK && ws { + if let Some(sink) = sink.take() { + self.ws_map.lock().await.insert(try_into_v4(addr), sink); + } + } + return res == register_pk_response::Result::OK; + } + Some(rendezvous_message::Union::OnlineRequest(or)) => { + let states = self.peers_online_state(or.peers).await; + let mut msg_out = RendezvousMessage::new(); + msg_out.set_online_response(OnlineResponse { + states: states.into(), + ..Default::default() + }); + Self::send_to_sink(sink, msg_out).await; + return true; } _ => {} } @@ -590,12 +673,7 @@ impl RendezvousServer { } #[inline] - async fn update_addr( - &mut self, - id: String, - socket_addr: SocketAddr, - socket: &mut FramedSocket, - ) -> ResultType<()> { + async fn update_addr(&mut self, id: String, socket_addr: SocketAddr) -> bool { let (request_pk, ip_change) = if let Some(old) = self.pm.get_in_memory(&id).await { let mut old = old.write().await; let ip = socket_addr.ip(); @@ -625,12 +703,93 @@ impl RendezvousServer { if let Some(old) = ip_change { log::info!("IP change of {} from {} to {}", id, old, socket_addr); } - let mut msg_out = RendezvousMessage::new(); - msg_out.set_register_peer_response(RegisterPeerResponse { - request_pk, - ..Default::default() - }); - socket.send(&msg_out, socket_addr).await + request_pk + } + + async fn handle_register_pk( + &mut self, + rk: RegisterPk, + addr: SocketAddr, + refresh_existing: bool, + ) -> Result { + if rk.uuid.is_empty() || rk.pk.is_empty() { + return Err(INVALID_ID_FORMAT); + } + let id = rk.id; + let ip = addr.ip().to_string(); + if id.len() < 6 { + return Err(UUID_MISMATCH); + } else if !self.check_ip_blocker(&ip, &id).await { + return Err(TOO_FREQUENT); + } + let peer = self.pm.get_or(&id).await; + let (changed, ip_changed) = { + let peer = peer.read().await; + if peer.uuid.is_empty() { + (true, false) + } else { + if peer.uuid == rk.uuid { + if peer.info.ip != ip && peer.pk != rk.pk { + log::warn!( + "Peer {} ip/pk mismatch: {}/{:?} vs {}/{:?}", + id, + ip, + rk.pk, + peer.info.ip, + peer.pk, + ); + drop(peer); + return Err(UUID_MISMATCH); + } + } else { + log::warn!( + "Peer {} uuid mismatch: {:?} vs {:?}", + id, + rk.uuid, + peer.uuid + ); + drop(peer); + return Err(UUID_MISMATCH); + } + let ip_changed = peer.info.ip != ip; + ( + peer.uuid != rk.uuid || peer.pk != rk.pk || ip_changed, + ip_changed, + ) + } + }; + let mut req_pk = peer.read().await.reg_pk; + if req_pk.1.elapsed().as_secs() > 6 { + req_pk.0 = 0; + } else if req_pk.0 > 2 { + return Err(TOO_FREQUENT); + } + req_pk.0 += 1; + req_pk.1 = Instant::now(); + peer.write().await.reg_pk = req_pk; + if ip_changed { + let mut lock = IP_CHANGES.lock().await; + if let Some((tm, ips)) = lock.get_mut(&id) { + if tm.elapsed().as_secs() > IP_CHANGE_DUR { + *tm = Instant::now(); + ips.clear(); + ips.insert(ip.clone(), 1); + } else if let Some(v) = ips.get_mut(&ip) { + *v += 1; + } else { + ips.insert(ip.clone(), 1); + } + } else { + lock.insert( + id.clone(), + (Instant::now(), HashMap::from([(ip.clone(), 1)])), + ); + } + } + if changed || refresh_existing { + self.pm.update_pk(id, peer, addr, rk.uuid, rk.pk, ip).await; + } + Ok(register_pk_response::Result::OK) } #[inline] @@ -717,6 +876,14 @@ impl RendezvousServer { }); return Ok((msg_out, None)); } + if let Some(other_failure) = Self::login_failure_message(&ph.token) { + let mut msg_out = RendezvousMessage::new(); + msg_out.set_punch_hole_response(PunchHoleResponse { + other_failure, + ..Default::default() + }); + return Ok((msg_out, None)); + } let id = ph.id; // punch hole request from A, relay to B, // check if in same intranet first, @@ -810,12 +977,39 @@ impl RendezvousServer { } } + fn login_failure_message(token: &str) -> Option { + if !MUST_LOGIN.load(Ordering::SeqCst) { + return None; + } + if jwt::secret().is_empty() { + return Some("JWT secret is not configured".to_owned()); + } + if token.is_empty() { + return Some("Connection failed, please login!".to_owned()); + } + jwt::verify_token(token) + .err() + .map(|_| "Token error, please log out and log back in!".to_owned()) + } + #[inline] async fn handle_online_request( &mut self, stream: &mut FramedStream, peers: Vec, ) -> ResultType<()> { + let states = self.peers_online_state(peers).await; + let mut msg_out = RendezvousMessage::new(); + msg_out.set_online_response(OnlineResponse { + states: states.into(), + ..Default::default() + }); + stream.send(&msg_out).await?; + + Ok(()) + } + + async fn peers_online_state(&mut self, peers: Vec) -> BytesMut { let mut states = BytesMut::zeroed((peers.len() + 7) / 8); for (i, peer_id) in peers.iter().enumerate() { if let Some(peer) = self.pm.get_in_memory(peer_id).await { @@ -828,15 +1022,7 @@ impl RendezvousServer { } } } - - let mut msg_out = RendezvousMessage::new(); - msg_out.set_online_response(OnlineResponse { - states: states.into(), - ..Default::default() - }); - stream.send(&msg_out).await?; - - Ok(()) + states } #[inline] @@ -850,16 +1036,7 @@ impl RendezvousServer { #[inline] async fn send_to_sink(sink: &mut Option, msg: RendezvousMessage) { if let Some(sink) = sink.as_mut() { - if let Ok(bytes) = msg.write_to_bytes() { - match sink { - Sink::TcpStream(s) => { - allow_err!(s.send(Bytes::from(bytes)).await); - } - Sink::Ws(ws) => { - allow_err!(ws.send(tungstenite::Message::Binary(bytes)).await); - } - } - } + sink.send(&msg).await; } } @@ -884,7 +1061,12 @@ impl RendezvousServer { ) -> ResultType<()> { let (msg, to_addr) = self.handle_punch_hole_request(addr, ph, key, ws).await?; if let Some(addr) = to_addr { - self.tx.send(Data::Msg(msg.into(), addr))?; + let mut sink = self.ws_map.lock().await.remove(&try_into_v4(addr)); + if let Some(sink) = sink.as_mut() { + sink.send(&msg).await; + } else { + self.tx.send(Data::Msg(msg.into(), addr))?; + } } else { self.send_to_tcp_sync(msg, addr).await?; } @@ -963,14 +1145,15 @@ impl RendezvousServer { match fds.next() { Some("h") => { res = format!( - "{}\n{}\n{}\n{}\n{}\n{}\n{}\n", + "{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n", "relay-servers(rs) ", "reload-geo(rg)", "ip-blocker(ib) [|] [-]", "ip-changes(ic) [|] [-]", "punch-requests(pr) [] [-]", - "always-use-relay(aur)", - "test-geo(tg) " + "always-use-relay(aur) [Y|N]", + "test-geo(tg) ", + "must-login(ml) [Y|N]" ) } Some("relay-servers" | "rs") => { @@ -1074,7 +1257,7 @@ impl RendezvousServer { let arg = fds.next(); if let Some("-") = arg { lock.clear(); } else { - let mut start = arg.and_then(|x| x.parse::().ok()).unwrap_or(0); + let start = arg.and_then(|x| x.parse::().ok()).unwrap_or(0); let mut page_size = fds.next().and_then(|x| x.parse::().ok()).unwrap_or(10); if page_size == 0 { page_size = 10; } for (_, e) in lock.iter().enumerate().skip(start).take(page_size) { @@ -1115,6 +1298,13 @@ impl RendezvousServer { } } } + Some("must-login" | "ml") => { + if let Some(value) = fds.next() { + MUST_LOGIN.store(value.to_uppercase() == "Y", Ordering::SeqCst); + } else { + let _ = writeln!(res, "MUST_LOGIN: {:?}", MUST_LOGIN.load(Ordering::SeqCst)); + } + } _ => {} } res @@ -1225,6 +1415,7 @@ impl RendezvousServer { } if sink.is_none() { self.tcp_punch.lock().await.remove(&try_into_v4(addr)); + self.ws_map.lock().await.remove(&try_into_v4(addr)); } log::debug!("Tcp connection from {:?} closed", addr); Ok(()) From fea8eaff1ae2642b81209f284b998a9d92a61572 Mon Sep 17 00:00:00 2001 From: HanGYvv Date: Sun, 9 Aug 2026 11:08:58 +0800 Subject: [PATCH 4/7] fix(build): vendored openssl for musl cross-builds hbb_common now pulls in native-tls -> openssl-sys on all platforms, but the cross musl images have no system OpenSSL, so every Linux target (amd64/arm64v8/armv7/i386) failed to compile in the release/docker/debian workflows. Build OpenSSL from source (vendored) on non-macOS/Windows targets, both as a normal dependency and as a build-dependency (build.rs also compiles hbb_common for the host). --- Cargo.lock | 11 +++++++++++ Cargo.toml | 7 +++++++ 2 files changed, 18 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 7c76b2b87..95712b313 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1703,6 +1703,7 @@ dependencies = [ "machine-uid 0.2.0", "minreq", "once_cell", + "openssl", "ping", "regex", "reqwest", @@ -2709,6 +2710,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + [[package]] name = "openssl-sys" version = "0.9.104" @@ -2717,6 +2727,7 @@ checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" dependencies = [ "cc", "libc", + "openssl-src", "pkg-config", "vcpkg", ] diff --git a/Cargo.toml b/Cargo.toml index 587965e30..76214b142 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,10 +63,17 @@ reqwest = { git = "https://github.com/rustdesk-org/reqwest", features = ["blocki [target.'cfg(not(any(target_os = "macos", target_os = "windows")))'.dependencies] reqwest = { git = "https://github.com/rustdesk-org/reqwest", features = ["blocking", "socks", "json", "rustls-tls", "rustls-tls-native-roots", "gzip"], default-features=false } +# hbb_common pulls in native-tls -> openssl-sys on all platforms; build OpenSSL +# from source so static musl cross-builds (cross images have no system OpenSSL). +openssl = { version = "0.10", features = ["vendored"] } [build-dependencies] hbb_common = { path = "libs/hbb_common" } +[target.'cfg(not(any(target_os = "macos", target_os = "windows")))'.build-dependencies] +# build.rs depends on hbb_common, which is compiled for the host as well. +openssl = { version = "0.10", features = ["vendored"] } + [workspace] members = ["libs/hbb_common"] exclude = ["ui"] From 375c3b667d10a274f29c864eccf4065325c052e3 Mon Sep 17 00:00:00 2001 From: HanGYvv Date: Sun, 9 Aug 2026 14:24:41 +0800 Subject: [PATCH 5/7] fix(hbbs): don't crash UDP loop on send errors UDP send errors (e.g. EINVAL when the destination port is 0, hit by the self-test heartbeat) only drop one datagram; the socket is still healthy. Treating them as fatal made the io_loop drop and recreate the socket in a crash loop. Keep recreation for receive-side failures only, and drop UDP packets from source port 0 outright (no legitimate client sends from port 0). --- src/rendezvous_server.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/rendezvous_server.rs b/src/rendezvous_server.rs index a86ad12ea..6d61da3df 100644 --- a/src/rendezvous_server.rs +++ b/src/rendezvous_server.rs @@ -314,8 +314,7 @@ impl RendezvousServer { match res { Some(Ok((bytes, addr))) => { if let Err(err) = self.handle_udp(&bytes, addr.into(), socket, key).await { - log::error!("udp failure: {}", err); - return LoopFailure::UdpSocket; + log::warn!("udp send/process failure (ignored): {}", err); } } Some(Err(err)) => { @@ -387,6 +386,10 @@ impl RendezvousServer { socket: &mut FramedSocket, key: &str, ) -> ResultType<()> { + if addr.port() == 0 { + log::debug!("drop UDP packet from source port 0: {}", addr); + return Ok(()); + } if let Ok(msg_in) = RendezvousMessage::parse_from_bytes(bytes) { match msg_in.union { Some(rendezvous_message::Union::RegisterPeer(rp)) => { From eaa9b569cbf5ecf20fab0a53107d9a711405b1e8 Mon Sep 17 00:00:00 2001 From: HanGYvv Date: Sun, 9 Aug 2026 14:24:47 +0800 Subject: [PATCH 6/7] fix(hbbs): deliver punch/relay over peer WS/TCP sinks Clients behind a reverse proxy register with an `IP:0` address that has no reachable UDP port, so RequestRelay (and punch retries) sent over UDP were silently dropped with EINVAL and relay could never be established. Route peer messages through the peer's registered WebSocket/TCP sink first via a shared send_to_peer helper, falling back to UDP only when no sink exists. --- src/rendezvous_server.rs | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/rendezvous_server.rs b/src/rendezvous_server.rs index 6d61da3df..b63b3e0ea 100644 --- a/src/rendezvous_server.rs +++ b/src/rendezvous_server.rs @@ -597,7 +597,7 @@ impl RendezvousServer { rf.socket_addr = AddrMangle::encode(addr).into(); msg_out.set_request_relay(rf); let peer_addr = peer.read().await.socket_addr; - self.tx.send(Data::Msg(msg_out.into(), peer_addr)).ok(); + self.send_to_peer(msg_out, peer_addr).await; } return true; } @@ -1054,6 +1054,19 @@ impl RendezvousServer { Ok(()) } + async fn send_to_peer(&mut self, msg: RendezvousMessage, peer_addr: SocketAddr) { + let key = try_into_v4(peer_addr); + let mut sink = self.ws_map.lock().await.remove(&key); + if sink.is_none() { + sink = self.tcp_punch.lock().await.remove(&key); + } + if let Some(sink) = sink.as_mut() { + sink.send(&msg).await; + } else { + self.tx.send(Data::Msg(msg.into(), peer_addr)).ok(); + } + } + #[inline] async fn handle_tcp_punch_hole_request( &mut self, @@ -1064,12 +1077,7 @@ impl RendezvousServer { ) -> ResultType<()> { let (msg, to_addr) = self.handle_punch_hole_request(addr, ph, key, ws).await?; if let Some(addr) = to_addr { - let mut sink = self.ws_map.lock().await.remove(&try_into_v4(addr)); - if let Some(sink) = sink.as_mut() { - sink.send(&msg).await; - } else { - self.tx.send(Data::Msg(msg.into(), addr))?; - } + self.send_to_peer(msg, addr).await; } else { self.send_to_tcp_sync(msg, addr).await?; } From 5e1c58634584aee8ac10fb12950c91d36b15366d Mon Sep 17 00:00:00 2001 From: HanGYvv Date: Sun, 9 Aug 2026 14:24:52 +0800 Subject: [PATCH 7/7] fix(hbbs): don't clobber real peer addr with reverse-proxy IP:0 A register_pk / register_peer over WebSocket behind a reverse proxy arrives with an address rewritten from X-Real-IP to `IP:0` (port 0 is a marker, not a reachable endpoint). Accepting it overwrote the peer's real UDP socket address that punching/relay depend on. Only update the stored address when the new one is non-zero, or when no real address is held yet. --- src/peer.rs | 4 +++- src/rendezvous_server.rs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/peer.rs b/src/peer.rs index 0f218dfdc..4970195e6 100644 --- a/src/peer.rs +++ b/src/peer.rs @@ -102,7 +102,9 @@ impl PeerMap { log::info!("update_pk {} {:?} {:?} {:?}", id, addr, uuid, pk); let (info_str, guid) = { let mut w = peer.write().await; - w.socket_addr = addr; + if addr.port() != 0 || w.socket_addr.port() == 0 { + w.socket_addr = addr; + } w.uuid = uuid.clone(); w.pk = pk.clone(); w.last_reg_time = Instant::now(); diff --git a/src/rendezvous_server.rs b/src/rendezvous_server.rs index b63b3e0ea..482ced86a 100644 --- a/src/rendezvous_server.rs +++ b/src/rendezvous_server.rs @@ -687,7 +687,9 @@ impl RendezvousServer { } && !ip.is_loopback(); let request_pk = old.pk.is_empty() || ip_change; if !request_pk { - old.socket_addr = socket_addr; + if socket_addr.port() != 0 || old.socket_addr.port() == 0 { + old.socket_addr = socket_addr; + } old.last_reg_time = Instant::now(); } let ip_change = if ip_change && old.reg_pk.0 <= 2 {