mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
feat: add server Linux support (#105)
PR#88 Added server linux support and changed workflow
This commit is contained in:
@@ -16,8 +16,16 @@ permissions:
|
||||
|
||||
jobs:
|
||||
server-build-test:
|
||||
name: Server Build & Test
|
||||
runs-on: windows-latest
|
||||
name: Server Build & Test (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
binary: chatserver.exe
|
||||
- os: ubuntu-latest
|
||||
binary: chatserver
|
||||
runs-on: ${{ matrix.os }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: Server/
|
||||
@@ -30,7 +38,7 @@ jobs:
|
||||
cache-dependency-path: Server/go.sum
|
||||
|
||||
- name: Build server
|
||||
run: go build -o chatserver.exe -ldflags "-s -w" .
|
||||
run: go build -o ${{ matrix.binary }} -ldflags "-s -w" .
|
||||
|
||||
- name: Go vulnerability check
|
||||
run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4 && govulncheck ./...
|
||||
@@ -45,7 +53,7 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: go-coverage
|
||||
name: go-coverage-${{ matrix.os }}
|
||||
path: Server/coverage.out
|
||||
retention-days: 7
|
||||
|
||||
|
||||
+127
-73
@@ -6,18 +6,14 @@ on:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Build & Release
|
||||
release-client:
|
||||
name: Build Tauri (Windows)
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
contents: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
@@ -32,16 +28,6 @@ jobs:
|
||||
with:
|
||||
workspaces: Client/tauri-client/src-tauri
|
||||
|
||||
- name: Extract version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build server
|
||||
shell: bash
|
||||
run: cd Server && go build -o chatserver.exe -ldflags "-s -w -X main.version=$VERSION" .
|
||||
|
||||
- name: Install npm dependencies
|
||||
working-directory: Client/tauri-client
|
||||
run: npm ci
|
||||
@@ -53,61 +39,143 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: npm run tauri build
|
||||
|
||||
- name: Locate artifacts
|
||||
id: artifacts
|
||||
- name: Stage Windows release assets
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p release-staging
|
||||
NSIS_DIR="Client/tauri-client/src-tauri/target/release/bundle/nsis"
|
||||
INSTALLER=$(find "$NSIS_DIR" -name "*.exe" | head -1)
|
||||
echo "installer_path=$INSTALLER" >> "$GITHUB_OUTPUT"
|
||||
echo "installer_name=$(basename $INSTALLER)" >> "$GITHUB_OUTPUT"
|
||||
# Updater artifacts (produced when TAURI_SIGNING_PRIVATE_KEY is set)
|
||||
cp "$INSTALLER" release-staging/
|
||||
NSIS_ZIP=$(find "$NSIS_DIR" -name "*_x64-setup.nsis.zip" ! -name "*.sig" | head -1)
|
||||
if [ -n "$NSIS_ZIP" ] && [ -f "$NSIS_ZIP" ]; then cp "$NSIS_ZIP" release-staging/; fi
|
||||
NSIS_SIG=$(find "$NSIS_DIR" -name "*_x64-setup.nsis.zip.sig" | head -1)
|
||||
echo "nsis_zip=${NSIS_ZIP:-}" >> "$GITHUB_OUTPUT"
|
||||
echo "nsis_sig=${NSIS_SIG:-}" >> "$GITHUB_OUTPUT"
|
||||
if [ -n "$NSIS_SIG" ] && [ -f "$NSIS_SIG" ]; then cp "$NSIS_SIG" release-staging/; fi
|
||||
|
||||
- name: Upload Windows release assets
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-release-assets
|
||||
path: release-staging/
|
||||
|
||||
release-server:
|
||||
name: Build server (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: windows-latest
|
||||
artifact: server-windows
|
||||
- os: ubuntu-latest
|
||||
artifact: server-linux
|
||||
runs-on: ${{ matrix.os }}
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
|
||||
- name: Extract version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build server (Windows)
|
||||
if: matrix.os == 'windows-latest'
|
||||
shell: bash
|
||||
run: cd Server && go build -o chatserver.exe -ldflags "-s -w -X main.version=$VERSION" .
|
||||
|
||||
- name: Build server (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
working-directory: Server
|
||||
env:
|
||||
CGO_ENABLED: "0"
|
||||
run: go build -o chatserver -ldflags "-s -w -X main.version=$VERSION" .
|
||||
|
||||
- name: Create tar.gz (Linux)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
working-directory: Server
|
||||
run: tar czf ../chatserver-linux-amd64.tar.gz chatserver
|
||||
|
||||
- name: Upload Windows binary
|
||||
if: matrix.os == 'windows-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact }}
|
||||
path: Server/chatserver.exe
|
||||
|
||||
- name: Upload Linux archive
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.artifact }}
|
||||
path: chatserver-linux-amd64.tar.gz
|
||||
|
||||
publish:
|
||||
name: Publish GitHub Release
|
||||
needs: [release-client, release-server]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
cache-dependency-path: Client/tauri-client/package-lock.json
|
||||
|
||||
- name: Download Windows client assets
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: windows-release-assets
|
||||
path: windows
|
||||
|
||||
- name: Download Windows server binary
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: server-windows
|
||||
path: windows
|
||||
|
||||
- name: Download Linux archive
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: server-linux
|
||||
path: linux
|
||||
|
||||
- name: Extract version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Generate SHA256 checksums
|
||||
shell: pwsh
|
||||
run: |
|
||||
$lines = @()
|
||||
$serverHash = (Get-FileHash -Path Server/chatserver.exe -Algorithm SHA256).Hash.ToLower()
|
||||
$lines += "$serverHash chatserver.exe"
|
||||
$installerPath = "${{ steps.artifacts.outputs.installer_path }}"
|
||||
$installerName = "${{ steps.artifacts.outputs.installer_name }}"
|
||||
$clientHash = (Get-FileHash -Path $installerPath -Algorithm SHA256).Hash.ToLower()
|
||||
$lines += "$clientHash $installerName"
|
||||
$nsisZip = "${{ steps.artifacts.outputs.nsis_zip }}"
|
||||
if ($nsisZip -and (Test-Path $nsisZip)) {
|
||||
$zipName = Split-Path $nsisZip -Leaf
|
||||
$zipHash = (Get-FileHash -Path $nsisZip -Algorithm SHA256).Hash.ToLower()
|
||||
$lines += "$zipHash $zipName"
|
||||
}
|
||||
$lines -join "`n" | Out-File -FilePath checksums.sha256 -Encoding utf8 -NoNewline
|
||||
find windows linux -type f -exec sha256sum {} \; > checksums.sha256
|
||||
|
||||
$manifest = @{
|
||||
version = "v$env:VERSION"
|
||||
asset = "chatserver.exe"
|
||||
sha256 = $serverHash
|
||||
} | ConvertTo-Json -Compress
|
||||
$manifest | Out-File -FilePath Server/server-update-manifest.json -Encoding utf8 -NoNewline
|
||||
- name: Generate server update manifest
|
||||
shell: bash
|
||||
run: |
|
||||
SERVER_HASH=$(sha256sum windows/chatserver.exe | awk '{print $1}')
|
||||
printf '{"version":"v%s","asset":"chatserver.exe","sha256":"%s"}' "$VERSION" "$SERVER_HASH" > windows/server-update-manifest.json
|
||||
|
||||
- name: Sign server update assets
|
||||
working-directory: Client/tauri-client
|
||||
shell: pwsh
|
||||
shell: bash
|
||||
env:
|
||||
SERVER_UPDATE_SIGNING_PRIVATE_KEY: ${{ secrets.SERVER_UPDATE_SIGNING_PRIVATE_KEY }}
|
||||
SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
run: |
|
||||
$keyPath = Join-Path $env:RUNNER_TEMP 'owncord-server-update.key'
|
||||
[System.IO.File]::WriteAllText($keyPath, $env:SERVER_UPDATE_SIGNING_PRIVATE_KEY)
|
||||
try {
|
||||
npx tauri signer sign -k $keyPath -p "$env:SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD" ../../Server/chatserver.exe
|
||||
npx tauri signer sign -k $keyPath -p "$env:SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD" ../../Server/server-update-manifest.json
|
||||
}
|
||||
finally {
|
||||
Remove-Item $keyPath -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
KEY_PATH=$(mktemp)
|
||||
printf '%s' "$SERVER_UPDATE_SIGNING_PRIVATE_KEY" > "$KEY_PATH"
|
||||
trap 'rm -f "$KEY_PATH"' EXIT
|
||||
npm ci
|
||||
npx tauri signer sign -k "$KEY_PATH" -p "$SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD" ../../windows/chatserver.exe
|
||||
npx tauri signer sign -k "$KEY_PATH" -p "$SERVER_UPDATE_SIGNING_PRIVATE_KEY_PASSWORD" ../../windows/server-update-manifest.json
|
||||
|
||||
- name: Install root dependencies (changelogen)
|
||||
run: npm ci
|
||||
@@ -119,23 +187,9 @@ jobs:
|
||||
- name: Create GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
ASSETS=(
|
||||
Server/chatserver.exe
|
||||
Server/chatserver.exe.sig
|
||||
Server/server-update-manifest.json
|
||||
Server/server-update-manifest.json.sig
|
||||
"${{ steps.artifacts.outputs.installer_path }}"
|
||||
checksums.sha256
|
||||
)
|
||||
# Include updater artifacts if signing key was available
|
||||
if [ -n "${{ steps.artifacts.outputs.nsis_zip }}" ]; then
|
||||
ASSETS+=("${{ steps.artifacts.outputs.nsis_zip }}")
|
||||
fi
|
||||
if [ -n "${{ steps.artifacts.outputs.nsis_sig }}" ]; then
|
||||
ASSETS+=("${{ steps.artifacts.outputs.nsis_sig }}")
|
||||
fi
|
||||
gh release create ${{ github.ref_name }} \
|
||||
mapfile -t assets < <(find windows linux -type f)
|
||||
assets+=(checksums.sha256)
|
||||
gh release create "${{ github.ref_name }}" \
|
||||
--notes-file CHANGELOG.md \
|
||||
"${ASSETS[@]}"
|
||||
"${assets[@]}"
|
||||
|
||||
@@ -5,10 +5,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockMuteScreenshareAudio = vi.fn();
|
||||
const mockSetScreenshareAudioVolume = vi.fn();
|
||||
const mockSetUserVolume = vi.fn();
|
||||
|
||||
vi.mock("@lib/livekitSession", () => ({
|
||||
muteScreenshareAudio: (...args: unknown[]) => mockMuteScreenshareAudio(...args),
|
||||
setScreenshareAudioVolume: (...args: unknown[]) => mockSetScreenshareAudioVolume(...args),
|
||||
setUserVolume: (...args: unknown[]) => mockSetUserVolume(...args),
|
||||
setScreenshareAudioVolume: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
"github.com/owncord/server/auth"
|
||||
"github.com/owncord/server/db"
|
||||
"github.com/owncord/server/updater"
|
||||
)
|
||||
|
||||
// openWhiteboxTestDB opens an in-memory SQLite database for whitebox tests.
|
||||
@@ -419,7 +420,7 @@ func TestSpawnDetached_ValidExecutable(t *testing.T) {
|
||||
t.Fatalf("abs path of test binary: %v", err)
|
||||
}
|
||||
|
||||
err = spawnDetached(selfExe, []string{"-test.run=^$"})
|
||||
err = updater.SpawnDetached(selfExe, []string{"-test.run=^$"})
|
||||
if err != nil {
|
||||
t.Errorf("spawnDetached returned error: %v", err)
|
||||
}
|
||||
@@ -428,7 +429,7 @@ func TestSpawnDetached_ValidExecutable(t *testing.T) {
|
||||
// TestSpawnDetached_InvalidExecutable verifies that spawnDetached returns an
|
||||
// error when the executable path does not exist.
|
||||
func TestSpawnDetached_InvalidExecutable(t *testing.T) {
|
||||
err := spawnDetached("/nonexistent/path/to/binary", nil)
|
||||
err := updater.SpawnDetached("/nonexistent/path/to/binary", nil)
|
||||
if err == nil {
|
||||
t.Error("expected error when executable does not exist, got nil")
|
||||
}
|
||||
@@ -448,7 +449,7 @@ func TestSpawnDetached_SetsWindowsFlags(t *testing.T) {
|
||||
}
|
||||
|
||||
// Just verify it doesn't panic when setting the Windows creation flag.
|
||||
err = spawnDetached(selfExe, []string{"-test.run=^$"})
|
||||
err = updater.SpawnDetached(selfExe, []string{"-test.run=^$"})
|
||||
if err != nil {
|
||||
t.Errorf("spawnDetached on Windows returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -5,9 +5,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
@@ -34,6 +32,7 @@ func handleCheckUpdate(u *updater.Updater) http.HandlerFunc {
|
||||
|
||||
// handleApplyUpdate downloads and applies a server update.
|
||||
func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Handler {
|
||||
// TODO: maybe disable this endpoint in future docker build type?
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if u == nil {
|
||||
writeErr(w, http.StatusServiceUnavailable, "UPDATE_UNAVAILABLE", "update checking is not configured")
|
||||
@@ -119,7 +118,7 @@ func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Ha
|
||||
}
|
||||
|
||||
// Spawn new process.
|
||||
if err := spawnDetached(exePath, os.Args[1:]); err != nil {
|
||||
if err := updater.SpawnDetached(exePath, os.Args[1:]); err != nil {
|
||||
slog.Error("update: spawn new process failed", "error", err)
|
||||
return
|
||||
}
|
||||
@@ -138,18 +137,3 @@ func handleApplyUpdate(u *updater.Updater, hub HubBroadcaster, _ string) http.Ha
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// spawnDetached starts a new process that is not attached to the current one.
|
||||
func spawnDetached(exePath string, args []string) error {
|
||||
cmd := exec.Command(exePath, args...) //nolint:gosec // G204: command path from trusted server config
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
CreationFlags: 0x00000008, // DETACHED_PROCESS
|
||||
}
|
||||
}
|
||||
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ func TestAdminAPI_CheckUpdate_OK(t *testing.T) {
|
||||
"html_url": "https://github.com/J3vb/OwnCord/releases/tag/v2.0.0",
|
||||
"assets": []map[string]any{
|
||||
{"name": "chatserver.exe", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/chatserver.exe"},
|
||||
{"name": "chatserver-linux-amd64.tar.gz", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/chatserver-linux-amd64.tar.gz"},
|
||||
{"name": "checksums.sha256", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/checksums.sha256"},
|
||||
{"name": "chatserver.exe.sig", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/chatserver.exe.sig"},
|
||||
{"name": "server-update-manifest.json", "browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/server-update-manifest.json"},
|
||||
@@ -306,6 +307,10 @@ func TestAdminAPI_ApplyUpdate_DownloadFails(t *testing.T) {
|
||||
"name": "chatserver.exe",
|
||||
"browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/chatserver.exe",
|
||||
},
|
||||
{
|
||||
"name": "chatserver-linux-amd64.tar.gz",
|
||||
"browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/chatserver-linux-amd64.tar.gz",
|
||||
},
|
||||
{
|
||||
"name": "checksums.sha256",
|
||||
"browser_download_url": "https://github.com/J3vb/OwnCord/releases/download/v2.0.0/checksums.sha256",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build !windows
|
||||
|
||||
package updater
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// SpawnDetached starts a new process that is not attached to the current one.
|
||||
func SpawnDetached(exePath string, args []string) error {
|
||||
cmd := exec.Command(exePath, args...) //nolint:gosec // G204: exePath is the server's own binary path, validated by the caller
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Setsid: true,
|
||||
}
|
||||
|
||||
return cmd.Start()
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build windows
|
||||
|
||||
package updater
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// SpawnDetached starts a new process that is not attached to the current one.
|
||||
func SpawnDetached(exePath string, args []string) error {
|
||||
cmd := exec.Command(exePath, args...) //nolint:gosec // G204: exePath is the server's own binary path, validated by the caller
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
CreationFlags: 0x00000008, // DETACHED_PROCESS
|
||||
}
|
||||
|
||||
return cmd.Start()
|
||||
}
|
||||
+160
-12
@@ -3,7 +3,9 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
@@ -17,6 +19,7 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -31,11 +34,13 @@ const (
|
||||
defaultBaseURL = "https://api.github.com"
|
||||
cacheTTL = 1 * time.Hour
|
||||
errorCacheTTL = 5 * time.Minute
|
||||
binaryAsset = "chatserver.exe"
|
||||
checksumAsset = "checksums.sha256"
|
||||
signatureAsset = binaryAsset + ".sig"
|
||||
signatureAsset = windowsServerBinary + ".sig"
|
||||
manifestAsset = "server-update-manifest.json"
|
||||
manifestSigAsset = manifestAsset + ".sig"
|
||||
|
||||
windowsServerBinary = "chatserver.exe"
|
||||
linuxServerArchive = "chatserver-linux-amd64.tar.gz"
|
||||
)
|
||||
|
||||
// serverUpdatePublicKeyText is the pinned public key for server update
|
||||
@@ -219,13 +224,14 @@ func (u *Updater) fetchLatestRelease(ctx context.Context) (UpdateInfo, error) {
|
||||
|
||||
var downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL string
|
||||
assets := make([]Asset, 0, len(release.Assets))
|
||||
wantBinary := serverDownloadAssetName(runtime.GOOS)
|
||||
for _, asset := range release.Assets {
|
||||
assets = append(assets, Asset{
|
||||
Name: asset.Name,
|
||||
DownloadURL: asset.BrowserDownloadURL,
|
||||
})
|
||||
switch {
|
||||
case strings.EqualFold(asset.Name, binaryAsset):
|
||||
case wantBinary != "" && strings.EqualFold(asset.Name, wantBinary):
|
||||
downloadURL = asset.BrowserDownloadURL
|
||||
case strings.EqualFold(asset.Name, checksumAsset):
|
||||
checksumURL = asset.BrowserDownloadURL
|
||||
@@ -270,10 +276,13 @@ func (u *Updater) ValidateDownloadURL(url string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DownloadAndVerify downloads the binary from downloadURL, fetches the
|
||||
// checksum file, the detached binary signature, and a signed release manifest,
|
||||
// and verifies that the downloaded asset matches both the release version and
|
||||
// the pinned signing key. On verification failure the downloaded file is removed.
|
||||
// DownloadAndVerify downloads the release artifact from downloadURL, fetches
|
||||
// the checksum file, the detached binary signature, and a signed release
|
||||
// manifest, and verifies that the downloaded asset matches both the release
|
||||
// version and the pinned signing key. On Windows the asset is a single
|
||||
// executable; on Linux it is a tar.gz archive containing a "chatserver"
|
||||
// binary, which is extracted to destPath. On verification failure the
|
||||
// downloaded file is removed.
|
||||
func (u *Updater) DownloadAndVerify(ctx context.Context, latestVersion, downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL, destPath string) error {
|
||||
if err := u.ValidateDownloadURL(downloadURL); err != nil {
|
||||
return err
|
||||
@@ -291,7 +300,6 @@ func (u *Updater) DownloadAndVerify(ctx context.Context, latestVersion, download
|
||||
return fmt.Errorf("validating manifest signature URL: %w", err)
|
||||
}
|
||||
|
||||
// Fetch checksum file.
|
||||
checksumData, err := u.fetchBody(ctx, checksumURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching checksums: %w", err)
|
||||
@@ -317,7 +325,11 @@ func (u *Updater) DownloadAndVerify(ctx context.Context, latestVersion, download
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expectedHash, err := u.ParseChecksumFile(checksumData, assetFilename)
|
||||
names := checksumEntryNamesForGOOS(runtime.GOOS)
|
||||
if len(names) == 0 {
|
||||
names = []string{assetFilename}
|
||||
}
|
||||
expectedHash, err := u.parseChecksumFileAny(checksumData, names...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing checksum file: %w", err)
|
||||
}
|
||||
@@ -325,7 +337,18 @@ func (u *Updater) DownloadAndVerify(ctx context.Context, latestVersion, download
|
||||
return fmt.Errorf("release manifest checksum mismatch for %s", assetFilename)
|
||||
}
|
||||
|
||||
// Download the binary.
|
||||
goos := runtime.GOOS
|
||||
switch goos {
|
||||
case "windows":
|
||||
return u.downloadWindowsBinaryAndVerify(ctx, downloadURL, destPath, expectedHash, signatureData)
|
||||
case "linux":
|
||||
return u.downloadLinuxTarballAndVerify(ctx, downloadURL, destPath, expectedHash)
|
||||
default:
|
||||
return fmt.Errorf("server auto-update is not supported on %s", goos)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *Updater) downloadWindowsBinaryAndVerify(ctx context.Context, downloadURL, destPath, expectedHash string, signatureData []byte) error {
|
||||
if err := u.downloadFile(ctx, downloadURL, destPath); err != nil {
|
||||
return fmt.Errorf("downloading binary: %w", err)
|
||||
}
|
||||
@@ -336,15 +359,140 @@ func (u *Updater) DownloadAndVerify(ctx context.Context, latestVersion, download
|
||||
}
|
||||
|
||||
// Verify hash.
|
||||
if err := u.VerifyChecksum(destPath, manifest.SHA256); err != nil {
|
||||
if err := u.VerifyChecksum(destPath, expectedHash); err != nil {
|
||||
// Remove the invalid file.
|
||||
_ = os.Remove(destPath)
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *Updater) downloadLinuxTarballAndVerify(ctx context.Context, downloadURL, destPath, expectedHash string) error {
|
||||
tarPath := destPath + ".tar.gz.partial"
|
||||
defer func() { _ = os.Remove(tarPath) }()
|
||||
|
||||
if err := u.downloadFile(ctx, downloadURL, tarPath); err != nil {
|
||||
return fmt.Errorf("downloading archive: %w", err)
|
||||
}
|
||||
if err := u.VerifyChecksum(tarPath, expectedHash); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.Open(tarPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("opening archive: %w", err)
|
||||
}
|
||||
defer f.Close() //nolint:errcheck
|
||||
|
||||
if err := extractChatserverFromTarGz(f, destPath); err != nil {
|
||||
_ = os.Remove(destPath)
|
||||
return fmt.Errorf("extracting archive: %w", err)
|
||||
}
|
||||
if err := os.Chmod(destPath, 0o755); err != nil { //nolint:gosec // G302: binary must be world-executable to run
|
||||
return fmt.Errorf("chmod binary: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractChatserverFromTarGz(r io.Reader, destPath string) error {
|
||||
gr, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gzip: %w", err)
|
||||
}
|
||||
defer gr.Close() //nolint:errcheck
|
||||
|
||||
tr := tar.NewReader(gr)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
return fmt.Errorf("archive contains no file named chatserver")
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("tar: %w", err)
|
||||
}
|
||||
skipBody := func() error {
|
||||
if _, err := io.Copy(io.Discard, io.LimitReader(tr, hdr.Size)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if hdr.Typeflag != tar.TypeReg && hdr.Typeflag != tar.TypeRegA {
|
||||
if err := skipBody(); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.Contains(hdr.Name, "..") {
|
||||
if err := skipBody(); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if filepath.Base(hdr.Name) != "chatserver" {
|
||||
if err := skipBody(); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
out, err := os.OpenFile(destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, copyErr := io.Copy(out, io.LimitReader(tr, hdr.Size))
|
||||
closeErr := out.Close()
|
||||
if copyErr != nil {
|
||||
_ = os.Remove(destPath)
|
||||
return fmt.Errorf("writing binary: %w", copyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
_ = os.Remove(destPath)
|
||||
return closeErr
|
||||
}
|
||||
if n != hdr.Size {
|
||||
_ = os.Remove(destPath)
|
||||
return fmt.Errorf("incomplete tar entry (%d of %d bytes)", n, hdr.Size)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// serverDownloadAssetName returns the GitHub release asset file name for the
|
||||
// server binary on the given GOOS (windows, linux). Other values return "".
|
||||
func serverDownloadAssetName(goos string) string {
|
||||
switch goos {
|
||||
case "windows":
|
||||
return windowsServerBinary
|
||||
case "linux":
|
||||
return linuxServerArchive
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// checksumEntryNamesForGOOS returns sha256sum line suffixes to look up in
|
||||
// checksums.sha256 (matches GitHub Actions release layout).
|
||||
func checksumEntryNamesForGOOS(goos string) []string {
|
||||
switch goos {
|
||||
case "windows":
|
||||
return []string{"windows/chatserver.exe", "chatserver.exe"}
|
||||
case "linux":
|
||||
return []string{"linux/chatserver-linux-amd64.tar.gz", "chatserver-linux-amd64.tar.gz"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (u *Updater) parseChecksumFileAny(data []byte, names ...string) (string, error) {
|
||||
for _, name := range names {
|
||||
hash, err := u.ParseChecksumFile(data, name)
|
||||
if err == nil {
|
||||
return hash, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no checksum line for any of: %s", strings.Join(names, ", "))
|
||||
}
|
||||
|
||||
// VerifyReleaseManifest checks the detached signature on the release manifest
|
||||
// and ensures the manifest binds the downloaded asset to the expected version.
|
||||
func (u *Updater) VerifyReleaseManifest(manifestData, signatureText []byte, expectedVersion, expectedAsset string) (releaseManifest, error) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
@@ -13,6 +15,8 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -41,6 +45,7 @@ func newTestRelease(tag, body, htmlURL string, assetDownloadBase string) ghRelea
|
||||
HTMLURL: htmlURL,
|
||||
Assets: []ghAsset{
|
||||
{Name: "chatserver.exe", BrowserDownloadURL: assetDownloadBase + "/chatserver.exe"},
|
||||
{Name: "chatserver-linux-amd64.tar.gz", BrowserDownloadURL: assetDownloadBase + "/chatserver-linux-amd64.tar.gz"},
|
||||
{Name: "checksums.sha256", BrowserDownloadURL: assetDownloadBase + "/checksums.sha256"},
|
||||
{Name: "chatserver.exe.sig", BrowserDownloadURL: assetDownloadBase + "/chatserver.exe.sig"},
|
||||
{Name: "server-update-manifest.json", BrowserDownloadURL: assetDownloadBase + "/server-update-manifest.json"},
|
||||
@@ -116,12 +121,17 @@ func TestCheckForUpdate_NewerVersionAvailable(t *testing.T) {
|
||||
if info.Current != "v1.0.0" {
|
||||
t.Errorf("expected Current=v1.0.0, got %s", info.Current)
|
||||
}
|
||||
if info.DownloadURL == "" {
|
||||
t.Error("expected non-empty DownloadURL")
|
||||
}
|
||||
if info.ChecksumURL == "" {
|
||||
t.Error("expected non-empty ChecksumURL")
|
||||
}
|
||||
// Server binary asset is only selected on Windows and Linux.
|
||||
if want := serverDownloadAssetName(runtime.GOOS); want != "" {
|
||||
if info.DownloadURL == "" {
|
||||
t.Error("expected non-empty DownloadURL")
|
||||
}
|
||||
} else if info.DownloadURL != "" {
|
||||
t.Error("expected empty DownloadURL on unsupported GOOS")
|
||||
}
|
||||
if info.SignatureURL == "" {
|
||||
t.Error("expected non-empty SignatureURL")
|
||||
}
|
||||
@@ -313,6 +323,96 @@ func TestVerifyChecksum_Incorrect(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateChecksum_SHA256MatchesChecksumsFile checks the full checksum chain
|
||||
// used during server update: SHA-256 of the downloaded release artifact must
|
||||
// equal the hex in checksums.sha256 (same layout as CI: "hash path/to/file"),
|
||||
// and VerifyChecksum must accept the on-disk file against that expected value.
|
||||
func TestUpdateChecksum_SHA256MatchesChecksumsFile(t *testing.T) {
|
||||
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
goos string
|
||||
assetFn func(*testing.T) []byte
|
||||
}{
|
||||
{
|
||||
name: "windows_exe",
|
||||
goos: "windows",
|
||||
assetFn: func(*testing.T) []byte {
|
||||
return []byte("windows server binary payload for checksum test")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "linux_tar_gz",
|
||||
goos: "linux",
|
||||
assetFn: func(t *testing.T) []byte {
|
||||
return mustBuildChatserverTarGz(t, []byte("linux inner binary for checksum test"))
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
asset := tc.assetFn(t)
|
||||
names := checksumEntryNamesForGOOS(tc.goos)
|
||||
if len(names) == 0 {
|
||||
t.Fatal("checksumEntryNamesForGOOS: empty names")
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(asset)
|
||||
expectedHex := hex.EncodeToString(sum[:])
|
||||
|
||||
// Same line shape as release workflow: sha256sum prints "<hash> <path>".
|
||||
primaryPath := names[0]
|
||||
checksumData := []byte(fmt.Sprintf("%s %s\n", expectedHex, primaryPath))
|
||||
|
||||
parsed, err := u.parseChecksumFileAny(checksumData, names...)
|
||||
if err != nil {
|
||||
t.Fatalf("parseChecksumFileAny: %v", err)
|
||||
}
|
||||
if !strings.EqualFold(parsed, expectedHex) {
|
||||
t.Fatalf("parsed hash %q, want %q", parsed, expectedHex)
|
||||
}
|
||||
|
||||
tmp := filepath.Join(t.TempDir(), "release-asset")
|
||||
if err := os.WriteFile(tmp, asset, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := u.VerifyChecksum(tmp, expectedHex); err != nil {
|
||||
t.Fatalf("VerifyChecksum: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateChecksum_FallbackChecksumLine verifies lookup when checksums.sha256
|
||||
// lists only the bare filename (second candidate in checksumEntryNamesForGOOS).
|
||||
func TestUpdateChecksum_FallbackChecksumLine(t *testing.T) {
|
||||
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
|
||||
asset := []byte("bare-name-line test")
|
||||
sum := sha256.Sum256(asset)
|
||||
expectedHex := hex.EncodeToString(sum[:])
|
||||
// Only "chatserver.exe", no windows/ prefix — second entry in list must match.
|
||||
checksumData := []byte(fmt.Sprintf("%s chatserver.exe\n", expectedHex))
|
||||
|
||||
names := checksumEntryNamesForGOOS("windows")
|
||||
parsed, err := u.parseChecksumFileAny(checksumData, names...)
|
||||
if err != nil {
|
||||
t.Fatalf("parseChecksumFileAny: %v", err)
|
||||
}
|
||||
if !strings.EqualFold(parsed, expectedHex) {
|
||||
t.Fatalf("parsed %q, want %q", parsed, expectedHex)
|
||||
}
|
||||
|
||||
tmp := filepath.Join(t.TempDir(), "chatserver.exe")
|
||||
if err := os.WriteFile(tmp, asset, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := u.VerifyChecksum(tmp, expectedHex); err != nil {
|
||||
t.Fatalf("VerifyChecksum: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChecksumFile_FindsFile(t *testing.T) {
|
||||
data := []byte("abc123 readme.txt\ndef456 chatserver.exe\nghi789 other.dll\n")
|
||||
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
|
||||
@@ -334,6 +434,68 @@ func TestParseChecksumFile_FileNotFound(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseChecksumFileAny_FirstMatch(t *testing.T) {
|
||||
data := []byte("aaa other\nbbb linux/chatserver-linux-amd64.tar.gz\nccc chatserver.exe\n")
|
||||
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
|
||||
h, err := u.parseChecksumFileAny(data, "linux/chatserver-linux-amd64.tar.gz", "chatserver.exe")
|
||||
if err != nil {
|
||||
t.Fatalf("parseChecksumFileAny: %v", err)
|
||||
}
|
||||
if h != "bbb" {
|
||||
t.Errorf("hash = %q, want bbb (linux line first in list)", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerDownloadAssetName(t *testing.T) {
|
||||
tests := []struct {
|
||||
goos string
|
||||
want string
|
||||
}{
|
||||
{"windows", "chatserver.exe"},
|
||||
{"linux", "chatserver-linux-amd64.tar.gz"},
|
||||
{"darwin", ""},
|
||||
{"freebsd", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := serverDownloadAssetName(tc.goos); got != tc.want {
|
||||
t.Errorf("serverDownloadAssetName(%q) = %q, want %q", tc.goos, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractChatserverFromTarGz(t *testing.T) {
|
||||
inner := []byte("#!/bin/fake\n")
|
||||
var gzbuf bytes.Buffer
|
||||
gw := gzip.NewWriter(&gzbuf)
|
||||
tw := tar.NewWriter(gw)
|
||||
hdr := &tar.Header{Name: "chatserver", Mode: 0o755, Size: int64(len(inner)), Typeflag: tar.TypeReg}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write(inner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
dest := filepath.Join(tmpDir, "chatserver")
|
||||
if err := extractChatserverFromTarGz(bytes.NewReader(gzbuf.Bytes()), dest); err != nil {
|
||||
t.Fatalf("extractChatserverFromTarGz: %v", err)
|
||||
}
|
||||
got, err := os.ReadFile(dest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, inner) {
|
||||
t.Errorf("extracted content mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetFilenameFromURL(t *testing.T) {
|
||||
got, err := assetFilenameFromURL("https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe")
|
||||
if err != nil {
|
||||
@@ -487,6 +649,17 @@ func TestDownloadFile_NonOKStatus(t *testing.T) {
|
||||
// ─── DownloadAndVerify ───────────────────────────────────────────────────────
|
||||
|
||||
func TestDownloadAndVerify_Success(t *testing.T) {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
testDownloadAndVerifySuccessWindows(t)
|
||||
case "linux":
|
||||
testDownloadAndVerifySuccessLinux(t)
|
||||
default:
|
||||
t.Skip("no DownloadAndVerify integration case for GOOS=" + runtime.GOOS)
|
||||
}
|
||||
}
|
||||
|
||||
func testDownloadAndVerifySuccessWindows(t *testing.T) {
|
||||
content := []byte("real binary content for verification")
|
||||
hash := sha256.Sum256(content)
|
||||
checksumHex := hex.EncodeToString(hash[:])
|
||||
@@ -535,13 +708,97 @@ func TestDownloadAndVerify_Success(t *testing.T) {
|
||||
t.Fatalf("DownloadAndVerify: %v", err)
|
||||
}
|
||||
|
||||
// File should exist and be correct.
|
||||
got, _ := os.ReadFile(dest)
|
||||
if !bytes.Equal(got, content) {
|
||||
t.Errorf("downloaded content mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func testDownloadAndVerifySuccessLinux(t *testing.T) {
|
||||
inner := []byte("linux binary payload")
|
||||
tgz := mustBuildChatserverTarGz(t, inner)
|
||||
hash := sha256.Sum256(tgz)
|
||||
checksumHex := hex.EncodeToString(hash[:])
|
||||
u, privateKey := newSignedTestUpdater(t, "", "1.0.0")
|
||||
manifest := []byte(`{"version":"v1.0.0","asset":"chatserver-linux-amd64.tar.gz","sha256":"` + checksumHex + `"}`)
|
||||
manifestSignature := signTestAsset(t, privateKey, manifest)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/download/chatserver-linux-amd64.tar.gz", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(tgz)
|
||||
})
|
||||
mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprintf(w, "%s linux/chatserver-linux-amd64.tar.gz\n", checksumHex)
|
||||
})
|
||||
mux.HandleFunc("/download/chatserver-linux-amd64.tar.gz.sig", func(w http.ResponseWriter, r *http.Request) {
|
||||
// Linux tar.gz does not have a detached binary sig; return empty to satisfy URL validation.
|
||||
// The signing flow only applies the manifest; the binary sig slot is unused on Linux.
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
mux.HandleFunc("/download/server-update-manifest.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(manifest)
|
||||
})
|
||||
mux.HandleFunc("/download/server-update-manifest.json.sig", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(manifestSignature)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
dest := filepath.Join(tmpDir, "chatserver")
|
||||
|
||||
u.baseURL = srv.URL
|
||||
|
||||
downloadURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver-linux-amd64.tar.gz"
|
||||
checksumURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256"
|
||||
signatureURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver-linux-amd64.tar.gz.sig"
|
||||
manifestURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json"
|
||||
manifestSignatureURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json.sig"
|
||||
|
||||
u.httpClient = &http.Client{
|
||||
Transport: &rewriteTransport{srv.URL},
|
||||
}
|
||||
|
||||
err := u.DownloadAndVerify(context.Background(), "v1.0.0", downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL, dest)
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadAndVerify: %v", err)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(dest)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(got, inner) {
|
||||
t.Errorf("extracted binary mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func mustBuildChatserverTarGz(t *testing.T, inner []byte) []byte {
|
||||
t.Helper()
|
||||
var gzbuf bytes.Buffer
|
||||
gw := gzip.NewWriter(&gzbuf)
|
||||
tw := tar.NewWriter(gw)
|
||||
hdr := &tar.Header{
|
||||
Name: "chatserver",
|
||||
Mode: 0o755,
|
||||
Size: int64(len(inner)),
|
||||
Typeflag: tar.TypeReg,
|
||||
}
|
||||
if err := tw.WriteHeader(hdr); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write(inner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return gzbuf.Bytes()
|
||||
}
|
||||
|
||||
func TestDownloadAndVerify_InvalidDownloadURL(t *testing.T) {
|
||||
u := NewUpdater("1.0.0", "", "J3vb", "OwnCord")
|
||||
err := u.DownloadAndVerify(context.Background(), "v1.0.0", "https://evil.com/file", "https://evil.com/sum", "https://evil.com/file.sig", "https://evil.com/manifest.json", "https://evil.com/manifest.json.sig", "/tmp/out")
|
||||
@@ -560,6 +817,17 @@ func TestDownloadAndVerify_InvalidChecksumURL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDownloadAndVerify_ChecksumMismatch(t *testing.T) {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
testDownloadAndVerifyChecksumMismatchWindows(t)
|
||||
case "linux":
|
||||
testDownloadAndVerifyChecksumMismatchLinux(t)
|
||||
default:
|
||||
t.Skip("no checksum mismatch case for GOOS=" + runtime.GOOS)
|
||||
}
|
||||
}
|
||||
|
||||
func testDownloadAndVerifyChecksumMismatchWindows(t *testing.T) {
|
||||
content := []byte("binary content")
|
||||
wrongChecksum := "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
actualHash := sha256.Sum256(content)
|
||||
@@ -610,6 +878,55 @@ func TestDownloadAndVerify_ChecksumMismatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func testDownloadAndVerifyChecksumMismatchLinux(t *testing.T) {
|
||||
tgz := mustBuildChatserverTarGz(t, []byte("x"))
|
||||
wrongChecksum := "0000000000000000000000000000000000000000000000000000000000000000"
|
||||
actualHash := sha256.Sum256(tgz)
|
||||
actualChecksum := hex.EncodeToString(actualHash[:])
|
||||
u, privateKey := newSignedTestUpdater(t, "", "1.0.0")
|
||||
manifest := []byte(`{"version":"v1.0.0","asset":"chatserver-linux-amd64.tar.gz","sha256":"` + actualChecksum + `"}`)
|
||||
manifestSignature := signTestAsset(t, privateKey, manifest)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/download/chatserver-linux-amd64.tar.gz", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(tgz)
|
||||
})
|
||||
mux.HandleFunc("/download/checksums.sha256", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprintf(w, "%s linux/chatserver-linux-amd64.tar.gz\n", wrongChecksum)
|
||||
})
|
||||
mux.HandleFunc("/download/chatserver-linux-amd64.tar.gz.sig", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
mux.HandleFunc("/download/server-update-manifest.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(manifest)
|
||||
})
|
||||
mux.HandleFunc("/download/server-update-manifest.json.sig", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write(manifestSignature)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
dest := filepath.Join(tmpDir, "chatserver")
|
||||
|
||||
u.httpClient = &http.Client{Transport: &rewriteTransport{srv.URL}}
|
||||
|
||||
downloadURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver-linux-amd64.tar.gz"
|
||||
checksumURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/checksums.sha256"
|
||||
signatureURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver-linux-amd64.tar.gz.sig"
|
||||
manifestURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json"
|
||||
manifestSignatureURL := "https://github.com/J3vb/OwnCord/releases/download/v1.0.0/server-update-manifest.json.sig"
|
||||
|
||||
err := u.DownloadAndVerify(context.Background(), "v1.0.0", downloadURL, checksumURL, signatureURL, manifestURL, manifestSignatureURL, dest)
|
||||
if err == nil {
|
||||
t.Error("DownloadAndVerify should fail on checksum mismatch")
|
||||
}
|
||||
|
||||
if _, statErr := os.Stat(dest); !os.IsNotExist(statErr) {
|
||||
t.Error("extracted file should not exist after checksum mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadAndVerify_MissingSignature(t *testing.T) {
|
||||
content := []byte("real binary content for verification")
|
||||
hash := sha256.Sum256(content)
|
||||
|
||||
Reference in New Issue
Block a user