package service import ( "archive/tar" "bytes" "compress/gzip" "context" "crypto/ed25519" "crypto/tls" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "runtime" "strings" "sync" "syscall" "time" "github.com/go-errors/errors" "golang.org/x/mod/semver" "github.com/phishingclub/phishingclub/build" "github.com/phishingclub/phishingclub/cache" "github.com/phishingclub/phishingclub/data" "github.com/phishingclub/phishingclub/embedded" "github.com/phishingclub/phishingclub/errs" "github.com/phishingclub/phishingclub/model" "github.com/phishingclub/phishingclub/version" ) type Update struct { Common OptionService *Option updateMutex sync.Mutex } // GitHubRelease represents a GitHub release response type GitHubRelease struct { TagName string `json:"tag_name"` Name string `json:"name"` Assets []struct { Name string `json:"name"` BrowserDownloadURL string `json:"browser_download_url"` } `json:"assets"` } // UpdateDetails represents update information type UpdateDetails struct { LatestVersion string `json:"latestVersion"` DownloadURL string `json:"downloadUrl"` Message string `json:"message"` } // CheckForUpdate returns if an update is ready and if installation // supports the update to be performed from the application func (u *Update) CheckForUpdate( ctx context.Context, session *model.Session, ) (bool, bool, error) { // check permissions isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) if err != nil { u.LogAuthError(err) return false, false, errs.Wrap(err) } if !isAuthorized { // skip audit logging on this endpoint return false, false, errors.New("unauthorized") } // Check GitHub for latest release updateAvailable, err := u.checkGitHubForUpdate() if err != nil { return false, false, errs.Wrap(err) } // Check if using systemd (for update capability) usingSystemd, err := u.OptionService.GetOption(ctx, session, data.OptionKeyUsingSystemd) if err != nil { return false, false, errs.Wrap(err) } return updateAvailable, usingSystemd.Value.String() == data.OptionValueUsingSystemdYes, nil } // CheckForUpdateCached returns if an update is ready based on cached data // and if installation supports the update to be performed from the application func (u *Update) CheckForUpdateCached( ctx context.Context, session *model.Session, ) (bool, bool, error) { // check permissions isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) if err != nil { u.LogAuthError(err) return false, false, errs.Wrap(err) } if !isAuthorized { // skip audit logging on this endpoint return false, false, errors.New("unauthorized") } // Get cached update availability updateAvailable := cache.IsUpdateAvailable() // Check if using systemd (for update capability) usingSystemd, err := u.OptionService.GetOption(ctx, session, data.OptionKeyUsingSystemd) if err != nil { return false, false, errs.Wrap(err) } return updateAvailable, usingSystemd.Value.String() == data.OptionValueUsingSystemdYes, nil } // checkGitHubForUpdate checks GitHub releases API for newer version func (u *Update) checkGitHubForUpdate() (bool, error) { client := &http.Client{ Timeout: 10 * time.Second, } if !build.Flags.Production { customTransport := &http.Transport{ TLSClientConfig: &tls.Config{ // #nosec InsecureSkipVerify: true, }, } client.Transport = customTransport } req, err := http.NewRequest( http.MethodGet, "https://api.github.com/repos/phishingclub/phishingclub/releases/latest", nil, ) if err != nil { return false, errs.Wrap(err) } req.Header.Set("Accept", "application/vnd.github.v3+json") req.Header.Set("User-Agent", "PhishingClub-Client") resp, err := client.Do(req) if err != nil { return false, errs.Wrap(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return false, errors.New("unexpected response from GitHub API") } var release GitHubRelease if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { return false, errs.Wrap(err) } currentVersion := version.GetSemver() latestVersion := release.TagName // Compare versions isNewer, err := u.CheckUpdateVersion(currentVersion, latestVersion) if err != nil { u.Logger.Errorw("version comparison failed", "error", err) return false, nil // Don't fail on version comparison error } // Cache the update availability cache.SetUpdateAvailable(isNewer) u.Logger.Debugw("update check completed", "current", currentVersion, "latest", latestVersion, "updateAvailable", isNewer) return isNewer, nil } func (u *Update) GetUpdateDetails( ctx context.Context, session *model.Session, ) (*UpdateDetails, error) { isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) if err != nil { u.LogAuthError(err) return nil, errs.Wrap(err) } if !isAuthorized { // skip audit logging on this endpoint return nil, errors.New("unauthorized") } // Get latest release info from GitHub client := &http.Client{ Timeout: 10 * time.Second, } if !build.Flags.Production { customTransport := &http.Transport{ TLSClientConfig: &tls.Config{ // #nosec InsecureSkipVerify: true, }, } client.Transport = customTransport } req, err := http.NewRequest( http.MethodGet, "https://api.github.com/repos/phishingclub/phishingclub/releases/latest", nil, ) if err != nil { return nil, errs.Wrap(err) } req.Header.Set("Accept", "application/vnd.github.v3+json") req.Header.Set("User-Agent", "PhishingClub-Client") resp, err := client.Do(req) if err != nil { return nil, errs.Wrap(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, errors.New("unexpected response from GitHub API") } var release GitHubRelease if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { return nil, errs.Wrap(err) } currentVersion := version.GetSemver() isNewer, _ := u.CheckUpdateVersion(currentVersion, release.TagName) if !isNewer { return nil, errs.ErrNoUpdateAvailable } // detect current system architecture arch := runtime.GOARCH expectedFilename := fmt.Sprintf("_linux_%s.tar.gz", arch) // find the binary asset matching current architecture var downloadURL string for _, asset := range release.Assets { if strings.Contains(asset.Name, expectedFilename) { downloadURL = asset.BrowserDownloadURL break } } if downloadURL == "" { return nil, fmt.Errorf("no downloadable binary found for architecture %s in latest release", arch) } return &UpdateDetails{ LatestVersion: release.TagName, DownloadURL: downloadURL, Message: "Update available from GitHub releases", }, nil } // RunUpdate runs a software update func (u *Update) RunUpdate( ctx context.Context, session *model.Session, ) error { // Prevent concurrent updates u.updateMutex.Lock() defer u.updateMutex.Unlock() ae := NewAuditEvent("Update.Run", session) isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) if err != nil { u.LogAuthError(err) return errs.Wrap(err) } if !isAuthorized { u.AuditLogNotAuthorized(ae) return errors.New("unauthorized") } // Get update details details, err := u.GetUpdateDetails(ctx, session) if err != nil { u.Logger.Errorw("failed to get update details", "error", err) return errs.Wrap(err) } // Download release from GitHub req, err := http.NewRequest( http.MethodGet, details.DownloadURL, nil, ) if err != nil { u.Logger.Errorw("failed to create download request", "error", err) return errs.Wrap(err) } req.Header.Set("User-Agent", "PhishingClub-Client") client := &http.Client{ Timeout: 3 * time.Minute, // extended timeout for downloads } if !build.Flags.Production { customTransport := &http.Transport{ TLSClientConfig: &tls.Config{ // #nosec InsecureSkipVerify: true, }, } client.Transport = customTransport } resp, err := client.Do(req) if err != nil { u.Logger.Errorw("failed to download update", "error", err) return errs.Wrap(err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { u.Logger.Errorw("unexpected response from downloading update", "statusCode", resp.StatusCode) return errors.New("unexpected response from GitHub") } currentVersion := version.GetSemver() // Check that the version is greater than the current // to protect against downgrade attacks isNewer, err := u.CheckUpdateVersion(currentVersion, details.LatestVersion) if err != nil { u.Logger.Errorf("version comparison failed", "error", err) // You might still want to proceed with the update } else if !isNewer { u.Logger.Infow("update is not newer than current version", "current", currentVersion, "latest", details.LatestVersion) return errors.New("update version is not newer than current") } // Get current executable path execPath, err := os.Executable() if err != nil { u.Logger.Errorw("failed to get current executable path", "error", err) return errs.Wrap(err) } execDir := filepath.Dir(execPath) // Get original permissions fileInfo, err := os.Stat(execPath) if err != nil { u.Logger.Errorw("failed to get original file permissions", "error", err) return errs.Wrap(err) } originalMode := fileInfo.Mode() // Create temp directory in same filesystem as executable tmpDir, err := os.MkdirTemp(execDir, ".update-*") if err != nil { return errs.Wrap(err) } defer os.RemoveAll(tmpDir) // Extract and verify the update package binaryPath, err := u.extractAndVerifyPackage(resp.Body, tmpDir) if err != nil { u.Logger.Errorw("failed to verify update package", "error", err) return errs.Wrap(err) } // if not production, we stop the upgrade process here if !build.Flags.Production { u.Logger.Infow("update verification successful (development mode - not installing)") return nil } // Create backup with atomic rename (same filesystem) backupPath := execPath + ".bak" if err := os.Rename(execPath, backupPath); err != nil { return errs.Wrap(err) } // Move new binary with atomic rename (same filesystem) if err := os.Rename(binaryPath, execPath); err != nil { // Restore from backup on failure os.Rename(backupPath, execPath) return errs.Wrap(err) } // After rename, set the same permissions as the original executable if err := os.Chmod(execPath, originalMode); err != nil { u.Logger.Errorw("failed to set original permissions", "error", err) return errs.Wrap(err) } u.Logger.Infow("update completed successfully", "version", details.LatestVersion) ae.Details["version"] = details.LatestVersion u.AuditLogAuthorized(ae) // Schedule shutdown after a brief delay to allow HTTP response to be sent go func() { time.Sleep(1 * time.Second) u.Logger.Infow("initiating shutdown after update") pid := os.Getpid() if process, err := os.FindProcess(pid); err == nil { process.Signal(syscall.SIGTERM) } }() return nil } // extractAndVerifyPackage extracts binary and signature from tar.gz and verifies the signature func (u *Update) extractAndVerifyPackage(packageData io.Reader, tmpDir string) (string, error) { // Read the entire package var buf bytes.Buffer if _, err := io.Copy(&buf, packageData); err != nil { return "", errs.Wrap(err) } // Create gzip reader gzipReader, err := gzip.NewReader(bytes.NewReader(buf.Bytes())) if err != nil { return "", errs.Wrap(err) } defer gzipReader.Close() // Create tar reader tarReader := tar.NewReader(gzipReader) var binaryPath, sigPath string // Extract files from archive for { header, err := tarReader.Next() if err == io.EOF { break } if err != nil { return "", errs.Wrap(err) } // Skip directories if header.Typeflag != tar.TypeReg { continue } // Get the base filename fileName := filepath.Base(header.Name) outputPath := filepath.Join(tmpDir, fileName) // Create output file outFile, err := os.Create(outputPath) if err != nil { return "", errs.Wrap(err) } // Copy file content if _, err := io.Copy(outFile, tarReader); err != nil { outFile.Close() return "", errs.Wrap(err) } outFile.Close() // Save paths based on file extension if filepath.Ext(fileName) == ".sig" { sigPath = outputPath } else if fileName == "phishingclub" { binaryPath = outputPath } } // Ensure we have both binary and signature if binaryPath == "" || sigPath == "" { return "", errors.New("update package is incomplete: missing binary or signature") } // Verify the signature if err := u.verifySignature(binaryPath, sigPath); err != nil { return "", errs.Wrap(err) } return binaryPath, nil } // verifySignature verifies binary using Ed25519 func (u *Update) verifySignature(binaryPath, sigPath string) error { // Load binary data binaryData, err := os.ReadFile(binaryPath) if err != nil { return errs.Wrap(err) } // Load signature signature, err := os.ReadFile(sigPath) if err != nil { return errs.Wrap(err) } // Verify using Ed25519 if !ed25519.Verify(embedded.SigningKey1, binaryData, signature) { u.Logger.Infow("failed to verify update - trying backup") if !ed25519.Verify(embedded.SigningKey2, binaryData, signature) { return errors.New("signature verification failed") } } return nil } func (u *Update) CheckUpdateVersion(currentVersion, latestVersion string) (bool, error) { if !build.Flags.Production { // ignroe version in development return false, nil } // The semver package expects versions to be prefixed with 'v' current := ensureVPrefix(currentVersion) latest := ensureVPrefix(latestVersion) // Validate versions if !semver.IsValid(current) { u.Logger.Errorw("invalid current version format", "version", currentVersion) return false, errs.Wrap(errors.New("invalid current version format")) } if !semver.IsValid(latest) { u.Logger.Errorw("invalid latest version format", "version", latestVersion) return false, errs.Wrap(errors.New("invalid latest version format")) } // Compare - returns > 0 if latest is greater return semver.Compare(latest, current) > 0, nil } // ensureVPrefix ensures the version string has a 'v' prefix func ensureVPrefix(version string) string { if !strings.HasPrefix(version, "v") { return "v" + version } return version }