feat: add server Linux support (#105)

PR#88 Added server linux support and changed workflow
This commit is contained in:
J3vb
2026-04-03 12:42:30 +02:00
committed by GitHub
10 changed files with 676 additions and 114 deletions
+12 -4
View File
@@ -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
View File
@@ -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(),
}));
+4 -3
View File
@@ -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)
}
+2 -18
View File
@@ -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()
}
+5
View File
@@ -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",
+21
View File
@@ -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()
}
+22
View File
@@ -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
View File
@@ -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) {
+321 -4
View File
@@ -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)