diff --git a/Server/config/config.go b/Server/config/config.go index 53cfe542..50393817 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -21,6 +21,12 @@ type Config struct { TLS TLSConfig `koanf:"tls"` Upload UploadConfig `koanf:"upload"` Voice VoiceConfig `koanf:"voice"` + GitHub GitHubConfig `koanf:"github"` +} + +// GitHubConfig holds GitHub API settings for update checking. +type GitHubConfig struct { + Token string `koanf:"token"` } // VoiceConfig holds STUN/TURN server settings for WebRTC signaling. @@ -80,6 +86,7 @@ func defaults() Config { TURNPort: 3478, TURNEnabled: true, }, + GitHub: GitHubConfig{}, } } @@ -102,6 +109,9 @@ tls: upload: max_size_mb: 100 storage_dir: "data/uploads" + +# github: +# token: "" # optional: GitHub API token for higher rate limits (5000 req/hr vs 60) ` // Load reads configuration from the given YAML file path, merging with diff --git a/Server/go.mod b/Server/go.mod index a2d9f45f..7af648ef 100644 --- a/Server/go.mod +++ b/Server/go.mod @@ -12,6 +12,7 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 go.yaml.in/yaml/v3 v3.0.3 golang.org/x/crypto v0.49.0 + golang.org/x/mod v0.34.0 modernc.org/sqlite v1.46.1 nhooyr.io/websocket v1.8.17 ) diff --git a/Server/go.sum b/Server/go.sum index e3be67e9..27903cef 100644 --- a/Server/go.sum +++ b/Server/go.sum @@ -58,8 +58,8 @@ golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= @@ -67,8 +67,8 @@ golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/Server/updater/updater.go b/Server/updater/updater.go new file mode 100644 index 00000000..c4dfb267 --- /dev/null +++ b/Server/updater/updater.go @@ -0,0 +1,306 @@ +// Package updater checks GitHub Releases for server updates and manages +// binary downloads with checksum verification. +package updater + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "golang.org/x/mod/semver" +) + +const ( + defaultBaseURL = "https://api.github.com" + cacheTTL = 1 * time.Hour + binaryAsset = "chatserver.exe" + checksumAsset = "checksums.sha256" +) + +// UpdateInfo holds the result of a version check. +type UpdateInfo struct { + Current string `json:"current"` + Latest string `json:"latest"` + UpdateAvailable bool `json:"update_available"` + ReleaseURL string `json:"release_url"` + DownloadURL string `json:"download_url"` + ChecksumURL string `json:"checksum_url"` + ReleaseNotes string `json:"release_notes"` +} + +// releaseResponse mirrors the subset of GitHub's release API we need. +type releaseResponse struct { + TagName string `json:"tag_name"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + Assets []assetResponse `json:"assets"` +} + +// assetResponse mirrors a single release asset from the GitHub API. +type assetResponse struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +// Updater checks GitHub Releases for updates and manages binary downloads. +type Updater struct { + currentVersion string + githubToken string + repoOwner string + repoName string + baseURL string // override for testing; empty uses defaultBaseURL + + cache *UpdateInfo + cacheExpiry time.Time + mu sync.Mutex + httpClient *http.Client +} + +// NewUpdater creates an Updater for the given repository. +func NewUpdater(currentVersion, githubToken, repoOwner, repoName string) *Updater { + return &Updater{ + currentVersion: currentVersion, + githubToken: githubToken, + repoOwner: repoOwner, + repoName: repoName, + httpClient: &http.Client{Timeout: 30 * time.Second}, + } +} + +// ensureVPrefix returns the version string with a "v" prefix for semver +// comparison. If it already has one, it is returned unchanged. +func ensureVPrefix(v string) string { + if strings.HasPrefix(v, "v") { + return v + } + return "v" + v +} + +// apiBaseURL returns the effective base URL for GitHub API requests. +func (u *Updater) apiBaseURL() string { + if u.baseURL != "" { + return u.baseURL + } + return defaultBaseURL +} + +// CheckForUpdate queries GitHub for the latest release and compares it +// against the current version. Results are cached for cacheTTL. +func (u *Updater) CheckForUpdate(ctx context.Context) (UpdateInfo, error) { + u.mu.Lock() + if u.cache != nil && time.Now().Before(u.cacheExpiry) { + cached := *u.cache + u.mu.Unlock() + return cached, nil + } + u.mu.Unlock() + + url := fmt.Sprintf("%s/repos/%s/%s/releases/latest", u.apiBaseURL(), u.repoOwner, u.repoName) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return UpdateInfo{}, fmt.Errorf("creating request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + if u.githubToken != "" { + req.Header.Set("Authorization", "token "+u.githubToken) + } + + resp, err := u.httpClient.Do(req) + if err != nil { + return UpdateInfo{}, fmt.Errorf("fetching latest release: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return UpdateInfo{}, fmt.Errorf("github API returned status %d", resp.StatusCode) + } + + var release releaseResponse + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return UpdateInfo{}, fmt.Errorf("decoding release response: %w", err) + } + + currentV := ensureVPrefix(u.currentVersion) + latestV := ensureVPrefix(release.TagName) + + // semver.Compare returns -1, 0, or +1. Update available when current < latest. + updateAvailable := semver.Compare(currentV, latestV) < 0 + + var downloadURL, checksumURL string + for _, asset := range release.Assets { + switch asset.Name { + case binaryAsset: + downloadURL = asset.BrowserDownloadURL + case checksumAsset: + checksumURL = asset.BrowserDownloadURL + } + } + + info := UpdateInfo{ + Current: currentV, + Latest: latestV, + UpdateAvailable: updateAvailable, + ReleaseURL: release.HTMLURL, + DownloadURL: downloadURL, + ChecksumURL: checksumURL, + ReleaseNotes: release.Body, + } + + u.mu.Lock() + u.cache = &info + u.cacheExpiry = time.Now().Add(cacheTTL) + u.mu.Unlock() + + return info, nil +} + +// ValidateDownloadURL ensures the URL points to an expected GitHub release +// asset for this repository. +func (u *Updater) ValidateDownloadURL(url string) error { + prefix := fmt.Sprintf("https://github.com/%s/%s/releases/download/", u.repoOwner, u.repoName) + if !strings.HasPrefix(url, prefix) { + return fmt.Errorf("download URL %q does not match expected prefix %q", url, prefix) + } + return nil +} + +// DownloadAndVerify downloads the binary from downloadURL, fetches the +// checksum file from checksumURL, and verifies the SHA256 hash matches. +// On checksum mismatch the downloaded file is removed. +func (u *Updater) DownloadAndVerify(ctx context.Context, downloadURL, checksumURL, destPath string) error { + if err := u.ValidateDownloadURL(downloadURL); err != nil { + return err + } + + // Fetch checksum file. + checksumData, err := u.fetchBody(ctx, checksumURL) + if err != nil { + return fmt.Errorf("fetching checksums: %w", err) + } + + destFilename := filepath.Base(destPath) + expectedHash, err := u.ParseChecksumFile(checksumData, destFilename) + if err != nil { + return fmt.Errorf("parsing checksum file: %w", err) + } + + // Download the binary. + if err := u.downloadFile(ctx, downloadURL, destPath); err != nil { + return fmt.Errorf("downloading binary: %w", err) + } + + // Verify hash. + if err := u.VerifyChecksum(destPath, expectedHash); err != nil { + // Remove the invalid file. + _ = os.Remove(destPath) + return err + } + + return nil +} + +// VerifyChecksum computes the SHA256 hash of the file at filePath and +// compares it (case-insensitive) against expectedHash. +func (u *Updater) VerifyChecksum(filePath, expectedHash string) error { + f, err := os.Open(filePath) + if err != nil { + return fmt.Errorf("opening file for checksum: %w", err) + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return fmt.Errorf("computing checksum: %w", err) + } + + actual := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(actual, expectedHash) { + return fmt.Errorf("checksum mismatch: expected %s, got %s", expectedHash, actual) + } + return nil +} + +// ParseChecksumFile parses a sha256sum-format checksum file (lines of +// " ") and returns the hash for the given filename. +func (u *Updater) ParseChecksumFile(data []byte, filename string) (string, error) { + lines := strings.Split(string(data), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // sha256sum format: " " (two spaces) + // Also handle single-space separation for robustness. + parts := strings.Fields(line) + if len(parts) >= 2 && parts[len(parts)-1] == filename { + return parts[0], nil + } + } + return "", fmt.Errorf("file %q not found in checksum data", filename) +} + +// fetchBody performs a GET request and returns the response body as bytes. +func (u *Updater) fetchBody(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + if u.githubToken != "" { + req.Header.Set("Authorization", "token "+u.githubToken) + } + + resp, err := u.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, url) + } + + return io.ReadAll(resp.Body) +} + +// downloadFile downloads the content at url and writes it to destPath. +func (u *Updater) downloadFile(ctx context.Context, url, destPath string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + if u.githubToken != "" { + req.Header.Set("Authorization", "token "+u.githubToken) + } + + resp, err := u.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d downloading %s", resp.StatusCode, url) + } + + f, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("creating destination file: %w", err) + } + defer f.Close() + + if _, err := io.Copy(f, resp.Body); err != nil { + return fmt.Errorf("writing downloaded file: %w", err) + } + + return nil +} diff --git a/Server/updater/updater_test.go b/Server/updater/updater_test.go new file mode 100644 index 00000000..2528bebe --- /dev/null +++ b/Server/updater/updater_test.go @@ -0,0 +1,265 @@ +package updater + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +// ghRelease mirrors the GitHub release API response shape. +type ghRelease struct { + TagName string `json:"tag_name"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + Assets []ghAsset `json:"assets"` +} + +// ghAsset mirrors a GitHub release asset. +type ghAsset struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` +} + +func newTestRelease(tag, body, htmlURL string, assetDownloadBase string) ghRelease { + return ghRelease{ + TagName: tag, + Body: body, + HTMLURL: htmlURL, + Assets: []ghAsset{ + {Name: "chatserver.exe", BrowserDownloadURL: assetDownloadBase + "/chatserver.exe"}, + {Name: "checksums.sha256", BrowserDownloadURL: assetDownloadBase + "/checksums.sha256"}, + }, + } +} + +func newTestServer(t *testing.T, release ghRelease, statusCode int) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/repos/J3vb/OwnCord/releases/latest", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + if statusCode == http.StatusOK { + if err := json.NewEncoder(w).Encode(release); err != nil { + t.Fatalf("encoding release: %v", err) + } + } else { + fmt.Fprint(w, `{"message":"Internal Server Error"}`) + } + }) + return httptest.NewServer(mux) +} + +func newTestUpdater(baseURL, currentVersion string) *Updater { + u := NewUpdater(currentVersion, "", "J3vb", "OwnCord") + u.baseURL = baseURL + return u +} + +func TestCheckForUpdate_NewerVersionAvailable(t *testing.T) { + release := newTestRelease("v1.2.0", "Bug fixes and improvements", "https://github.com/J3vb/OwnCord/releases/tag/v1.2.0", + "https://github.com/J3vb/OwnCord/releases/download/v1.2.0") + srv := newTestServer(t, release, http.StatusOK) + defer srv.Close() + + u := newTestUpdater(srv.URL, "1.0.0") + info, err := u.CheckForUpdate(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !info.UpdateAvailable { + t.Error("expected UpdateAvailable=true, got false") + } + if info.Latest != "v1.2.0" { + t.Errorf("expected Latest=v1.2.0, got %s", info.Latest) + } + 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") + } +} + +func TestCheckForUpdate_UpToDate(t *testing.T) { + release := newTestRelease("v1.0.0", "Current release", "https://github.com/J3vb/OwnCord/releases/tag/v1.0.0", + "https://github.com/J3vb/OwnCord/releases/download/v1.0.0") + srv := newTestServer(t, release, http.StatusOK) + defer srv.Close() + + u := newTestUpdater(srv.URL, "1.0.0") + info, err := u.CheckForUpdate(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.UpdateAvailable { + t.Error("expected UpdateAvailable=false, got true") + } +} + +func TestCheckForUpdate_CachesResult(t *testing.T) { + var hitCount atomic.Int32 + release := newTestRelease("v2.0.0", "Major update", "https://github.com/J3vb/OwnCord/releases/tag/v2.0.0", + "https://github.com/J3vb/OwnCord/releases/download/v2.0.0") + + mux := http.NewServeMux() + mux.HandleFunc("/repos/J3vb/OwnCord/releases/latest", func(w http.ResponseWriter, r *http.Request) { + hitCount.Add(1) + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(release); err != nil { + t.Fatalf("encoding release: %v", err) + } + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + u := newTestUpdater(srv.URL, "1.0.0") + ctx := context.Background() + + _, err := u.CheckForUpdate(ctx) + if err != nil { + t.Fatalf("first call error: %v", err) + } + _, err = u.CheckForUpdate(ctx) + if err != nil { + t.Fatalf("second call error: %v", err) + } + + if got := hitCount.Load(); got != 1 { + t.Errorf("expected 1 API hit (cached), got %d", got) + } +} + +func TestCheckForUpdate_CacheExpires(t *testing.T) { + var hitCount atomic.Int32 + release := newTestRelease("v2.0.0", "Major update", "https://github.com/J3vb/OwnCord/releases/tag/v2.0.0", + "https://github.com/J3vb/OwnCord/releases/download/v2.0.0") + + mux := http.NewServeMux() + mux.HandleFunc("/repos/J3vb/OwnCord/releases/latest", func(w http.ResponseWriter, r *http.Request) { + hitCount.Add(1) + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(release); err != nil { + t.Fatalf("encoding release: %v", err) + } + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + u := newTestUpdater(srv.URL, "1.0.0") + ctx := context.Background() + + // First call populates cache. + _, err := u.CheckForUpdate(ctx) + if err != nil { + t.Fatalf("first call error: %v", err) + } + + // Expire the cache manually. + u.mu.Lock() + u.cacheExpiry = time.Now().Add(-1 * time.Minute) + u.mu.Unlock() + + // Second call should hit the API again. + _, err = u.CheckForUpdate(ctx) + if err != nil { + t.Fatalf("second call error: %v", err) + } + + if got := hitCount.Load(); got != 2 { + t.Errorf("expected 2 API hits (cache expired), got %d", got) + } +} + +func TestCheckForUpdate_APIError(t *testing.T) { + release := ghRelease{} // unused since status is 500 + srv := newTestServer(t, release, http.StatusInternalServerError) + defer srv.Close() + + u := newTestUpdater(srv.URL, "1.0.0") + _, err := u.CheckForUpdate(context.Background()) + if err == nil { + t.Fatal("expected error for 500 response, got nil") + } +} + +func TestValidateDownloadURL_Valid(t *testing.T) { + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + err := u.ValidateDownloadURL("https://github.com/J3vb/OwnCord/releases/download/v1.0.0/chatserver.exe") + if err != nil { + t.Errorf("expected valid URL to pass, got error: %v", err) + } +} + +func TestValidateDownloadURL_Invalid(t *testing.T) { + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + err := u.ValidateDownloadURL("https://evil.com/chatserver.exe") + if err == nil { + t.Error("expected invalid URL to be rejected, got nil") + } +} + +func TestVerifyChecksum_Correct(t *testing.T) { + content := []byte("hello world binary content") + hash := sha256.Sum256(content) + expectedHash := hex.EncodeToString(hash[:]) + + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "chatserver.exe") + if err := os.WriteFile(filePath, content, 0o644); err != nil { + t.Fatalf("writing temp file: %v", err) + } + + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + if err := u.VerifyChecksum(filePath, expectedHash); err != nil { + t.Errorf("expected correct checksum to pass, got error: %v", err) + } +} + +func TestVerifyChecksum_Incorrect(t *testing.T) { + content := []byte("hello world binary content") + + tmpDir := t.TempDir() + filePath := filepath.Join(tmpDir, "chatserver.exe") + if err := os.WriteFile(filePath, content, 0o644); err != nil { + t.Fatalf("writing temp file: %v", err) + } + + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + wrongHash := "0000000000000000000000000000000000000000000000000000000000000000" + if err := u.VerifyChecksum(filePath, wrongHash); err == nil { + t.Error("expected incorrect checksum to fail, got nil") + } +} + +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") + hash, err := u.ParseChecksumFile(data, "chatserver.exe") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if hash != "def456" { + t.Errorf("expected hash=def456, got %s", hash) + } +} + +func TestParseChecksumFile_FileNotFound(t *testing.T) { + data := []byte("abc123 readme.txt\ndef456 chatserver.exe\n") + u := NewUpdater("1.0.0", "", "J3vb", "OwnCord") + _, err := u.ParseChecksumFile(data, "nonexistent.exe") + if err == nil { + t.Error("expected error for missing file in checksum data, got nil") + } +}