From 9ad7f0abdb413317acb11a19f44772483ecfe760 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Sat, 13 Jun 2026 19:47:25 +0300 Subject: [PATCH] Align Android Argus publication with SSH contract --- Android/docs/argus-rollout-policy.md | 7 +- .../settings/update/AppUpdateManager.kt | 89 +++- .../src/main/res/values-ru/strings.xml | 2 +- .../qsferaApp/src/main/res/values/strings.xml | 2 +- Android/scripts/prepare-argus-publication.ps1 | 6 + Android/scripts/publish-release.ps1 | 475 +++++++++++++----- PRODUCTION_READINESS.md | 8 +- scripts/verify-production-state.ps1 | 20 +- 8 files changed, 462 insertions(+), 147 deletions(-) diff --git a/Android/docs/argus-rollout-policy.md b/Android/docs/argus-rollout-policy.md index 7c268531..ef1e15c2 100644 --- a/Android/docs/argus-rollout-policy.md +++ b/Android/docs/argus-rollout-policy.md @@ -5,12 +5,15 @@ - Android update requirement: Android Developers says an update must use the same application ID and the same signing certificate as the installed app, unless it contains a valid proof-of-rotation: . - QSfera update implementation: `Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/update/AppUpdateManager.kt`. - QSfera Android version and release signing env names: `Android/qsferaApp/build.gradle`. +- Argus publication authority: `G:/Repos/Marketplace/ARGUS_PUBLICATION.md`. - Current Argus release and certificate facts verified for this production-readiness pass: `PRODUCTION_READINESS.md`, section `Android Argus Publication Check`. ## Policy - Stable Argus releases for `qsfera-mobile` must keep the same package name and production signing certificate after the first production-signed `1.3.11` / `39` release. -- The in-app updater must not bypass certificate mismatch. `AppUpdateManager` validates package name, monotonically newer `versionCode`, Argus `androidVersionCode`, and signer digest before starting Android package installation. +- Argus publication is SSH-only and must follow `G:/Repos/Marketplace/ARGUS_PUBLICATION.md`: copy the APK to the Pi, write metadata to `/srv/argus-data/argus.db`, and store package bytes under `/srv/argus-data/Packages`. Do not add or use HTTP `/api/admin/*` publication routes from this repository. +- The in-app updater must use the public read-only manifest endpoint, treat HTTP `404` as no update, compare `release.version` with the installed version, download `ARGUS_BASE_URL + release.downloadPath`, verify `release.packageSizeBytes` and `release.sha256`, and only then start Android package installation. +- The in-app updater must not bypass certificate mismatch. `AppUpdateManager` validates package name, newer installed APK metadata, optional Argus `androidVersionCode` when present, manifest version, and signer digest before starting Android package installation. - A client installed from the debug-signed `1.3.5` / `33` Argus APK cannot be moved to the current production-signed stable APK (`1.3.13` / `41` as of 2026-06-12) by the in-app updater because the signer digest check intentionally fails. - For debug-signed `1.3.5` installs, the supported migration path is: back up unsynced local files, uninstall the debug-signed build, then install the current stable QSfera APK from Argus. - On certificate mismatch, the Android app must show an explicit reinstall-required dialog instead of a generic update error. @@ -21,5 +24,5 @@ 2. Prepare Argus publication only from a clean release branch whose `HEAD` matches its upstream; `Android/scripts/prepare-argus-publication.ps1` enforces this by default and records repository root, branch, upstream, commit, and verification time in `argus-publication.json`. When the Android checkout is used from the pushed `QSfera.git` monorepo, run it from `Android/` with `-GitProvenanceRoot ..`. 3. Verify the local release signing material and latest Argus APK certificate with `Android/scripts/verify-release-signing-material.ps1 -VerifyPublishedApk`. 4. Verify the APK certificate before upload with `apksigner verify --print-certs`. -5. Publish to Argus only after certificate, package name, version code, SHA-256, and git provenance checks pass. +5. Publish to Argus only through `Android/scripts/publish-release.ps1`, which implements the SSH/SQLite/Data/Packages workflow from `G:/Repos/Marketplace/ARGUS_PUBLICATION.md`; do not publish through HTTP admin APIs. 6. Verify the published manifest and download with `scripts/verify-production-state.ps1 -VerifyAndroidDownload` from the repository root. diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/update/AppUpdateManager.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/update/AppUpdateManager.kt index 7593a550..0f50bd2b 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/update/AppUpdateManager.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/update/AppUpdateManager.kt @@ -44,7 +44,7 @@ class AppUpdateManager(private val fragment: Fragment) { runCatching { withContext(Dispatchers.IO) { fetchLatestRelease(context) } }.onSuccess { release -> - if (release.androidVersionCode <= BuildConfig.VERSION_CODE) { + if (release == null || compareVersions(release.version, BuildConfig.VERSION_NAME) <= 0) { showNoUpdatesDialog(context) } else { showUpdateDialog(context, release) @@ -62,9 +62,7 @@ class AppUpdateManager(private val fragment: Fragment) { context.getString( R.string.update_available_message, release.version, - release.androidVersionCode, - BuildConfig.VERSION_NAME, - BuildConfig.VERSION_CODE + BuildConfig.VERSION_NAME ) ) .setNegativeButton(android.R.string.cancel, null) @@ -95,8 +93,13 @@ class AppUpdateManager(private val fragment: Fragment) { fragment.lifecycleScope.launch { runCatching { withContext(Dispatchers.IO) { - downloadApk(context, release).also { apk -> + val apk = downloadApk(context, release) + try { validateDownloadedApk(context, apk, release) + apk + } catch (error: Throwable) { + apk.delete() + throw error } } }.onSuccess { apk -> @@ -139,25 +142,45 @@ class AppUpdateManager(private val fragment: Fragment) { .show() } - private fun fetchLatestRelease(context: Context): ArgusRelease { - val json = requestText(ARGUS_MANIFEST_URL) + private fun fetchLatestRelease(context: Context): ArgusRelease? { + val json = requestText(ARGUS_MANIFEST_URL) ?: return null val release = JSONObject(json).optJSONObject("release") ?: error(context.getString(R.string.update_release_not_found)) + val version = release.getString("version") + val channel = release.optString("channel") + val platform = release.optString("platform") + val packageKind = release.optString("packageKind") + val downloadPath = release.getString("downloadPath") + val sha256 = release.optString("sha256") + val packageSizeBytes = release.optLong("packageSizeBytes", -1L) + + if (channel != ARGUS_CHANNEL || + platform != ARGUS_PLATFORM || + packageKind != ARGUS_PACKAGE_KIND || + sha256.isBlank() || + packageSizeBytes <= 0L + ) { + error(context.getString(R.string.update_metadata_error)) + } return ArgusRelease( - version = release.getString("version"), - androidVersionCode = release.getInt("androidVersionCode"), - downloadUrl = release.getString("downloadPath").toAbsoluteArgusUrl(), - sha256 = release.optString("sha256") + version = version, + androidVersionCode = release.optInt("androidVersionCode").takeIf { it > 0 }, + downloadUrl = downloadPath.toAbsoluteArgusUrl(), + packageSizeBytes = packageSizeBytes, + sha256 = sha256.lowercase() ) } - private fun requestText(url: String): String { + private fun requestText(url: String): String? { val connection = URL(url).openConnection() as HttpURLConnection return try { connection.connectTimeout = NETWORK_TIMEOUT_MS connection.readTimeout = NETWORK_TIMEOUT_MS connection.requestMethod = "GET" + if (connection.responseCode == HttpURLConnection.HTTP_NOT_FOUND) { + return null + } check(connection.responseCode in 200..299) { "HTTP ${connection.responseCode}" } @@ -170,6 +193,9 @@ class AppUpdateManager(private val fragment: Fragment) { private fun downloadApk(context: Context, release: ArgusRelease): File { val updatesDir = File(context.filesDir, "updates").apply { mkdirs() } val apkFile = File(updatesDir, UPDATE_APK_NAME) + if (apkFile.exists()) { + apkFile.delete() + } val connection = URL(release.downloadUrl).openConnection() as HttpURLConnection try { @@ -188,7 +214,13 @@ class AppUpdateManager(private val fragment: Fragment) { connection.disconnect() } - if (release.sha256.isNotBlank() && apkFile.sha256() != release.sha256.lowercase()) { + if (apkFile.length() != release.packageSizeBytes) { + apkFile.delete() + error(context.getString(R.string.update_metadata_error)) + } + + if (apkFile.sha256() != release.sha256) { + apkFile.delete() error(context.getString(R.string.update_digest_error)) } @@ -220,7 +252,10 @@ class AppUpdateManager(private val fragment: Fragment) { if (downloadedVersionCode <= BuildConfig.VERSION_CODE) { error(context.getString(R.string.update_version_error)) } - if (downloadedVersionCode != release.androidVersionCode.toLong()) { + if (release.androidVersionCode != null && downloadedVersionCode != release.androidVersionCode.toLong()) { + error(context.getString(R.string.update_metadata_error)) + } + if (downloadedPackageInfo.versionName != release.version) { error(context.getString(R.string.update_metadata_error)) } @@ -326,10 +361,29 @@ class AppUpdateManager(private val fragment: Fragment) { return digest.digest().joinToString("") { "%02x".format(it) } } + private fun compareVersions(candidate: String, installed: String): Int { + val candidateParts = candidate.toVersionParts() + val installedParts = installed.toVersionParts() + val maxParts = maxOf(candidateParts.size, installedParts.size) + for (index in 0 until maxParts) { + val candidatePart = candidateParts.getOrElse(index) { 0 } + val installedPart = installedParts.getOrElse(index) { 0 } + if (candidatePart != installedPart) { + return candidatePart.compareTo(installedPart) + } + } + return candidate.compareTo(installed, ignoreCase = true) + } + + private fun String.toVersionParts(): List = + split('.', '-', '_') + .mapNotNull { part -> part.takeWhile { it.isDigit() }.toIntOrNull() } + private data class ArgusRelease( val version: String, - val androidVersionCode: Int, + val androidVersionCode: Int?, val downloadUrl: String, + val packageSizeBytes: Long, val sha256: String ) @@ -337,8 +391,11 @@ class AppUpdateManager(private val fragment: Fragment) { companion object { private const val ARGUS_BASE_URL = "https://argus.kusoft.xyz" + private const val ARGUS_PLATFORM = "android" + private const val ARGUS_CHANNEL = "stable" + private const val ARGUS_PACKAGE_KIND = "apk" private const val ARGUS_MANIFEST_URL = - "$ARGUS_BASE_URL/api/apps/qsfera-mobile/manifest?platform=android&channel=stable" + "$ARGUS_BASE_URL/api/apps/qsfera-mobile/manifest?platform=$ARGUS_PLATFORM&channel=$ARGUS_CHANNEL" private const val UPDATE_APK_NAME = "qsfera-update.apk" private const val APK_MIME_TYPE = "application/vnd.android.package-archive" private const val NETWORK_TIMEOUT_MS = 15_000 diff --git a/Android/qsferaApp/src/main/res/values-ru/strings.xml b/Android/qsferaApp/src/main/res/values-ru/strings.xml index 5568f08a..3505370b 100644 --- a/Android/qsferaApp/src/main/res/values-ru/strings.xml +++ b/Android/qsferaApp/src/main/res/values-ru/strings.xml @@ -110,7 +110,7 @@ Проверка обновлений… Скачивание обновления… Доступно обновление - Доступна версия %1$s (%2$d). Текущая версия: %3$s (%4$d). + Доступна версия %1$s. Текущая версия: %2$s. Обновлений нет Установлена последняя доступная версия. Установить diff --git a/Android/qsferaApp/src/main/res/values/strings.xml b/Android/qsferaApp/src/main/res/values/strings.xml index dcb0e703..1c7986e1 100644 --- a/Android/qsferaApp/src/main/res/values/strings.xml +++ b/Android/qsferaApp/src/main/res/values/strings.xml @@ -112,7 +112,7 @@ Checking for updates… Downloading update… Update available - Version %1$s (%2$d) is available. Current version: %3$s (%4$d). + Version %1$s is available. Current version: %2$s. No updates You are using the latest available version. Install diff --git a/Android/scripts/prepare-argus-publication.ps1 b/Android/scripts/prepare-argus-publication.ps1 index 08568c8d..fda03fee 100644 --- a/Android/scripts/prepare-argus-publication.ps1 +++ b/Android/scripts/prepare-argus-publication.ps1 @@ -314,6 +314,8 @@ if (-not (Test-Path $stageDirectory)) { $stagedApkName = "{0}-{1}.apk" -f $Slug, $versionCode $stagedApkPath = Join-Path $stageDirectory $stagedApkName Copy-Item $releaseApkPath $stagedApkPath -Force +$stagedApkSize = (Get-Item -LiteralPath $stagedApkPath).Length +$stagedApkSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $stagedApkPath).Hash.ToLowerInvariant() $metadata = [ordered]@{ packagePath = $stagedApkPath @@ -328,6 +330,10 @@ $metadata = [ordered]@{ notes = if ($Notes) { $Notes } else { $null } repositoryUrl = if ($RepositoryUrl) { $RepositoryUrl } else { $null } homepageUrl = if ($HomepageUrl) { $HomepageUrl } else { $null } + packageSizeBytes = $stagedApkSize + sha256 = $stagedApkSha256 + publicationMethod = 'ssh-sqlite-data-packages' + publicationInstruction = 'G:/Repos/Marketplace/ARGUS_PUBLICATION.md' git = $gitProvenance androidPackageName = $packageName androidVersionCode = [int]$versionCode diff --git a/Android/scripts/publish-release.ps1 b/Android/scripts/publish-release.ps1 index 015bde3c..7a8530bb 100644 --- a/Android/scripts/publish-release.ps1 +++ b/Android/scripts/publish-release.ps1 @@ -1,141 +1,372 @@ param( - [string]$BaseUrl = 'https://argus.kusoft.xyz', - [Parameter(Mandatory = $true)] - [string]$Username, - [Parameter(Mandatory = $true)] - [string]$Password, - [Parameter(Mandatory = $true)] - [string]$Slug, - [Parameter(Mandatory = $true)] - [string]$Name, - [Parameter(Mandatory = $true)] - [string]$Summary, - [Parameter(Mandatory = $true)] - [string]$Description, - [Parameter(Mandatory = $true)] - [string]$AndroidPackageName, - [Parameter(Mandatory = $true)] - [string]$Version, - [Parameter(Mandatory = $true)] - [ValidateRange(1, [int]::MaxValue)] - [int]$AndroidVersionCode, - [Parameter(Mandatory = $true)] - [string]$PackagePath, - [string]$Channel = 'stable', - [string]$Platform = 'android', - [string]$PackageKind = 'apk', - [string]$Notes, - [string]$RepositoryUrl, - [string]$HomepageUrl = 'https://qsfera.kusoft.xyz', - [switch]$Unlisted + [string]$MetadataPath, + [string]$PublicBaseUrl = 'https://argus.kusoft.xyz', + [string]$RemoteHost = '192.168.0.185', + [string]$RemoteUser = 'sevenhill', + [int]$RemotePort = 22, + [string]$RemoteTempDirectory = '/tmp', + [string]$ArgusData = '/srv/argus-data', + [string]$GeneratedScriptPath, + [switch]$PrepareOnly, + [switch]$RestartArgus ) $ErrorActionPreference = 'Stop' -Add-Type -AssemblyName System.Net.Http -function Read-HttpBody([System.Net.Http.HttpResponseMessage]$Response) { - if ($null -eq $Response.Content) { - return '' +function Fail([string]$Message) { + Write-Error $Message + exit 1 +} + +function Get-LatestMetadataPath { + $repoRoot = Split-Path -Parent $PSScriptRoot + $publicationRoot = Join-Path $repoRoot 'build\argus-publication' + if (-not (Test-Path -LiteralPath $publicationRoot)) { + Fail "Argus publication directory was not found: $publicationRoot" } - return $Response.Content.ReadAsStringAsync().GetAwaiter().GetResult() -} - -function Ensure-Success( - [System.Net.Http.HttpResponseMessage]$Response, - [string]$Operation -) { - if ($Response.IsSuccessStatusCode) { - return + $metadata = Get-ChildItem -LiteralPath $publicationRoot -Recurse -File -Filter 'argus-publication.json' | + Sort-Object LastWriteTimeUtc -Descending | + Select-Object -First 1 + if (-not $metadata) { + Fail "No argus-publication.json file was found under $publicationRoot" } - $body = Read-HttpBody $Response - throw "$Operation failed with status $([int]$Response.StatusCode) ($($Response.ReasonPhrase)). Body: $body" + return $metadata.FullName } -function New-JsonContent([string]$Json) { - return [System.Net.Http.StringContent]::new($Json, [System.Text.Encoding]::UTF8, 'application/json') +function Get-RequiredString($Object, [string]$Name) { + $property = $Object.PSObject.Properties[$Name] + if ($null -eq $property -or [string]::IsNullOrWhiteSpace([string]$property.Value)) { + Fail "Publication metadata is missing '$Name'." + } + + return [string]$property.Value } -$resolvedPackagePath = (Resolve-Path -LiteralPath $PackagePath).Path -if (-not (Test-Path $resolvedPackagePath)) { - throw "Package file was not found: $resolvedPackagePath" +function Quote-BashSingle([string]$Value) { + if ($null -eq $Value) { + $Value = '' + } + + return "'" + ($Value -replace "'", "'\''") + "'" } -$baseUri = $BaseUrl.TrimEnd('/') -$cookieContainer = [System.Net.CookieContainer]::new() -$handler = [System.Net.Http.HttpClientHandler]::new() -$handler.CookieContainer = $cookieContainer -$handler.UseCookies = $true -$client = [System.Net.Http.HttpClient]::new($handler) -$client.Timeout = [TimeSpan]::FromMinutes(15) +function Join-RemotePath([string]$Directory, [string]$Leaf) { + return $Directory.TrimEnd('/') + '/' + $Leaf +} + +function Invoke-CheckedNative([string]$Program, [string[]]$Arguments) { + & $Program @Arguments + if ($LASTEXITCODE -ne 0) { + Fail "$Program failed with exit code $LASTEXITCODE." + } +} + +function Assert-Slug([string]$Slug) { + if ($Slug -notmatch '^[a-z0-9][a-z0-9-]{0,99}$') { + Fail "Argus slug must contain only lowercase letters, digits, and hyphens, max 100 chars: $Slug" + } +} + +function Assert-ManifestValue($Manifest, [string]$Name, $Actual, $Expected) { + if ([string]$Actual -ne [string]$Expected) { + Fail "Published manifest $Name expected '$Expected' but got '$Actual'." + } +} + +if ([string]::IsNullOrWhiteSpace($MetadataPath)) { + $MetadataPath = Get-LatestMetadataPath +} + +$resolvedMetadataPath = (Resolve-Path -LiteralPath $MetadataPath).Path +$metadata = Get-Content -LiteralPath $resolvedMetadataPath -Raw -Encoding UTF8 | ConvertFrom-Json + +$packagePath = (Resolve-Path -LiteralPath (Get-RequiredString $metadata 'packagePath')).Path +$slug = Get-RequiredString $metadata 'slug' +$name = Get-RequiredString $metadata 'name' +$summary = Get-RequiredString $metadata 'summary' +$description = Get-RequiredString $metadata 'description' +$version = Get-RequiredString $metadata 'version' +$channel = (Get-RequiredString $metadata 'channel').ToLowerInvariant() +$platform = (Get-RequiredString $metadata 'platform').ToLowerInvariant() +$packageKind = (Get-RequiredString $metadata 'packageKind').ToLowerInvariant() +$notes = if ($metadata.PSObject.Properties['notes']) { [string]$metadata.notes } else { '' } +$repositoryUrl = if ($metadata.PSObject.Properties['repositoryUrl']) { [string]$metadata.repositoryUrl } else { '' } +$homepageUrl = if ($metadata.PSObject.Properties['homepageUrl']) { [string]$metadata.homepageUrl } else { '' } +$isListed = if ($metadata.PSObject.Properties['isListed'] -and [bool]$metadata.isListed) { '1' } else { '0' } + +Assert-Slug $slug +if ($platform -ne 'android') { + Fail "QSfera Android publication requires platform=android. Actual platform: $platform" +} +if ($packageKind -ne 'apk') { + Fail "QSfera Android publication requires packageKind=apk. Actual packageKind: $packageKind" +} +if (-not $packagePath.EndsWith('.apk', [System.StringComparison]::OrdinalIgnoreCase)) { + Fail "QSfera Android publication requires an APK package: $packagePath" +} + +$scp = Get-Command scp -ErrorAction SilentlyContinue +$ssh = Get-Command ssh -ErrorAction SilentlyContinue +if (-not $scp -or -not $ssh) { + Fail 'Both scp and ssh must be available for SSH-only Argus publication.' +} + +$localSize = (Get-Item -LiteralPath $packagePath).Length +$localSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $packagePath).Hash.ToLowerInvariant() +$packageFileName = Split-Path -Leaf $packagePath +$remotePackagePath = Join-RemotePath $RemoteTempDirectory $packageFileName +$token = [guid]::NewGuid().ToString('N') +$remoteScriptPath = Join-RemotePath $RemoteTempDirectory "$slug-publish-$token.sh" +$localScriptPath = if ([string]::IsNullOrWhiteSpace($GeneratedScriptPath)) { + Join-Path ([System.IO.Path]::GetTempPath()) "$slug-publish-$token.sh" +} else { + $GeneratedScriptPath +} +$remote = "$RemoteUser@$RemoteHost" +$sshPort = [string]$RemotePort + +$restartCommand = if ($RestartArgus) { + 'docker restart argus >/dev/null' +} else { + ':' +} + +$scriptTemplate = @' +set -euo pipefail + +cleanup() { + rm -f "$SOURCE_FILE" "$0" +} +trap cleanup EXIT + +export ARGUS_DATA=__ARGUS_DATA__ +export SOURCE_FILE=__SOURCE_FILE__ + +export ARGUS_SLUG=__ARGUS_SLUG__ +export ARGUS_NAME=__ARGUS_NAME__ +export ARGUS_SUMMARY=__ARGUS_SUMMARY__ +export ARGUS_DESCRIPTION=__ARGUS_DESCRIPTION__ +export ARGUS_REPOSITORY_URL=__ARGUS_REPOSITORY_URL__ +export ARGUS_HOMEPAGE_URL=__ARGUS_HOMEPAGE_URL__ +export ARGUS_IS_LISTED=__ARGUS_IS_LISTED__ + +export ARGUS_VERSION=__ARGUS_VERSION__ +export ARGUS_CHANNEL=__ARGUS_CHANNEL__ +export ARGUS_PLATFORM=__ARGUS_PLATFORM__ +export ARGUS_PACKAGE_KIND=__ARGUS_PACKAGE_KIND__ +export ARGUS_NOTES=__ARGUS_NOTES__ + +python3 - "$SOURCE_FILE" <<'PY' +import hashlib +import mimetypes +import os +import re +import shutil +import sqlite3 +import sys +import uuid +from datetime import datetime, timezone +from pathlib import Path + +source = Path(sys.argv[1]).resolve() +data_root = Path(os.environ.get("ARGUS_DATA", "/srv/argus-data")).resolve() +db_path = data_root / "argus.db" +packages_root = data_root / "Packages" + +slug = os.environ["ARGUS_SLUG"].strip() +name = os.environ["ARGUS_NAME"].strip() +summary = os.environ["ARGUS_SUMMARY"].strip() +description = os.environ["ARGUS_DESCRIPTION"].strip() +repository_url = os.environ.get("ARGUS_REPOSITORY_URL", "").strip() or None +homepage_url = os.environ.get("ARGUS_HOMEPAGE_URL", "").strip() or None +is_listed = 1 if os.environ.get("ARGUS_IS_LISTED", "1").strip() != "0" else 0 + +version = os.environ["ARGUS_VERSION"].strip() +channel = os.environ.get("ARGUS_CHANNEL", "stable").strip().lower() +platform = os.environ.get("ARGUS_PLATFORM", "generic").strip().lower() +package_kind = os.environ.get("ARGUS_PACKAGE_KIND", "binary").strip().lower() +notes = os.environ.get("ARGUS_NOTES", "").strip() or None + +if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,99}", slug): + raise SystemExit("ARGUS_SLUG must contain only lowercase letters, digits, and hyphens, max 100 chars.") +if not source.is_file(): + raise SystemExit(f"SOURCE_FILE does not exist: {source}") +if not db_path.is_file(): + raise SystemExit(f"Argus database does not exist: {db_path}") +for key, value in { + "ARGUS_NAME": name, + "ARGUS_SUMMARY": summary, + "ARGUS_DESCRIPTION": description, + "ARGUS_VERSION": version, +}.items(): + if not value: + raise SystemExit(f"{key} is required.") + +release_id = str(uuid.uuid4()) +now = datetime.now(timezone.utc).isoformat(timespec="microseconds") +safe_version = re.sub(r"[^a-zA-Z0-9._-]+", "-", version).strip("-._") or "release" +extension = source.suffix or ".bin" +stored_name = f"{datetime.now(timezone.utc):%Y%m%d%H%M%S}-{safe_version}-{release_id.replace('-', '')}{extension}" +stored_relative_path = f"{slug}/{stored_name}" +target_dir = packages_root / slug +target_path = target_dir / stored_name + +target_dir.mkdir(parents=True, exist_ok=True) +shutil.copy2(source, target_path) + +size_bytes = target_path.stat().st_size +sha256 = hashlib.sha256(target_path.read_bytes()).hexdigest() +content_type = mimetypes.guess_type(source.name)[0] or "application/octet-stream" +if source.suffix.lower() == ".apk": + content_type = "application/vnd.android.package-archive" + +conn = sqlite3.connect(db_path) +try: + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("BEGIN") + + row = conn.execute('SELECT "Id" FROM "Apps" WHERE "Slug" = ?', (slug,)).fetchone() + if row is None: + app_id = str(uuid.uuid4()) + conn.execute( + ''' + INSERT INTO "Apps" + ("Id", "Slug", "Name", "Summary", "Description", "RepositoryUrl", "HomepageUrl", "IsListed", "CreatedAt", "UpdatedAt") + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', + (app_id, slug, name, summary, description, repository_url, homepage_url, is_listed, now, now), + ) + else: + app_id = row[0] + conn.execute( + ''' + UPDATE "Apps" + SET "Name" = ?, + "Summary" = ?, + "Description" = ?, + "RepositoryUrl" = ?, + "HomepageUrl" = ?, + "IsListed" = ?, + "UpdatedAt" = ? + WHERE "Id" = ? + ''', + (name, summary, description, repository_url, homepage_url, is_listed, now, app_id), + ) + + duplicate = conn.execute( + ''' + SELECT "Id" + FROM "Releases" + WHERE "CatalogAppId" = ? AND "Version" = ? AND "Channel" = ? AND "Platform" = ? + ''', + (app_id, version, channel, platform), + ).fetchone() + if duplicate is not None: + raise RuntimeError(f"Release already exists for {slug} {version} {channel} {platform}.") + + conn.execute( + ''' + INSERT INTO "Releases" + ("Id", "CatalogAppId", "Version", "Channel", "Platform", "PackageKind", + "OriginalFileName", "StoredRelativePath", "ContentType", "PackageSizeBytes", + "Sha256", "Notes", "PublishedAt") + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', + ( + release_id, + app_id, + version, + channel, + platform, + package_kind, + source.name, + stored_relative_path, + content_type, + size_bytes, + sha256, + notes, + now, + ), + ) + + conn.commit() +except Exception: + conn.rollback() + try: + target_path.unlink() + except FileNotFoundError: + pass + raise +finally: + conn.close() + +print(f"Published app={slug} version={version} channel={channel} platform={platform}") +print(f"StoredRelativePath={stored_relative_path}") +print(f"Size={size_bytes}") +print(f"Sha256={sha256}") +print(f"Manifest=https://argus.kusoft.xyz/api/apps/{slug}/manifest?platform={platform}&channel={channel}") +PY + +curl -fsS "http://127.0.0.1:5105/api/apps/$ARGUS_SLUG/manifest?platform=$ARGUS_PLATFORM&channel=$ARGUS_CHANNEL" +__RESTART_COMMAND__ +'@ + +$publicationScript = $scriptTemplate. + Replace('__ARGUS_DATA__', (Quote-BashSingle $ArgusData)). + Replace('__SOURCE_FILE__', (Quote-BashSingle $remotePackagePath)). + Replace('__ARGUS_SLUG__', (Quote-BashSingle $slug)). + Replace('__ARGUS_NAME__', (Quote-BashSingle $name)). + Replace('__ARGUS_SUMMARY__', (Quote-BashSingle $summary)). + Replace('__ARGUS_DESCRIPTION__', (Quote-BashSingle $description)). + Replace('__ARGUS_REPOSITORY_URL__', (Quote-BashSingle $repositoryUrl)). + Replace('__ARGUS_HOMEPAGE_URL__', (Quote-BashSingle $homepageUrl)). + Replace('__ARGUS_IS_LISTED__', (Quote-BashSingle $isListed)). + Replace('__ARGUS_VERSION__', (Quote-BashSingle $version)). + Replace('__ARGUS_CHANNEL__', (Quote-BashSingle $channel)). + Replace('__ARGUS_PLATFORM__', (Quote-BashSingle $platform)). + Replace('__ARGUS_PACKAGE_KIND__', (Quote-BashSingle $packageKind)). + Replace('__ARGUS_NOTES__', (Quote-BashSingle $notes)). + Replace('__RESTART_COMMAND__', $restartCommand) + +[System.IO.File]::WriteAllText($localScriptPath, $publicationScript, [System.Text.UTF8Encoding]::new($false)) + +if ($PrepareOnly) { + Write-Output 'QSfera Android Argus SSH publication script prepared.' + Write-Output "Script: $localScriptPath" + Write-Output "APK: $packagePath" + Write-Output "Remote APK: $remotePackagePath" + Write-Output "Size: $localSize" + Write-Output "SHA-256: $localSha256" + exit 0 +} try { - $loginPayload = @{ - username = $Username - password = $Password - } | ConvertTo-Json -Compress - - $loginResponse = $client.PostAsync( - "$baseUri/api/admin/session/login", - (New-JsonContent $loginPayload) - ).GetAwaiter().GetResult() - Ensure-Success $loginResponse 'Argus login' - - $appPayload = @{ - name = $Name - summary = $Summary - description = $Description - androidPackageName = $AndroidPackageName - repositoryUrl = if ([string]::IsNullOrWhiteSpace($RepositoryUrl)) { $null } else { $RepositoryUrl } - homepageUrl = if ([string]::IsNullOrWhiteSpace($HomepageUrl)) { $null } else { $HomepageUrl } - isListed = (-not $Unlisted) - } | ConvertTo-Json -Compress - - $appResponse = $client.PutAsync( - "$baseUri/api/admin/apps/$Slug", - (New-JsonContent $appPayload) - ).GetAwaiter().GetResult() - Ensure-Success $appResponse 'Argus app metadata update' - - $form = [System.Net.Http.MultipartFormDataContent]::new() - $fields = [ordered]@{ - Version = $Version - Channel = $Channel - Platform = $Platform - PackageKind = $PackageKind - AndroidVersionCode = [string]$AndroidVersionCode - } - - foreach ($entry in $fields.GetEnumerator()) { - $form.Add([System.Net.Http.StringContent]::new($entry.Value, [System.Text.Encoding]::UTF8), $entry.Key) - } - - if (-not [string]::IsNullOrWhiteSpace($Notes)) { - $form.Add([System.Net.Http.StringContent]::new($Notes, [System.Text.Encoding]::UTF8), 'Notes') - } - - $fileStream = [System.IO.File]::OpenRead($resolvedPackagePath) - try { - $fileContent = [System.Net.Http.StreamContent]::new($fileStream) - $fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/vnd.android.package-archive') - $form.Add($fileContent, 'Package', [System.IO.Path]::GetFileName($resolvedPackagePath)) - - $releaseResponse = $client.PostAsync( - "$baseUri/api/admin/apps/$Slug/releases", - $form - ).GetAwaiter().GetResult() - Ensure-Success $releaseResponse 'Argus release upload' - - $releaseBody = Read-HttpBody $releaseResponse - if (-not [string]::IsNullOrWhiteSpace($releaseBody)) { - Write-Output $releaseBody - } - } finally { - $fileStream.Dispose() - $form.Dispose() - } + Invoke-CheckedNative $scp.Source @('-P', $sshPort, $packagePath, "${remote}:$remotePackagePath") + Invoke-CheckedNative $scp.Source @('-P', $sshPort, $localScriptPath, "${remote}:$remoteScriptPath") + Invoke-CheckedNative $ssh.Source @('-p', $sshPort, $remote, "bash $(Quote-BashSingle $remoteScriptPath)") } finally { - $client.Dispose() - $handler.Dispose() + Remove-Item -LiteralPath $localScriptPath -Force -ErrorAction SilentlyContinue } + +$manifestUrl = "$($PublicBaseUrl.TrimEnd('/'))/api/apps/$slug/manifest?platform=$platform&channel=$channel" +$manifest = Invoke-RestMethod -Uri $manifestUrl -TimeoutSec 30 +$release = $manifest.release +if ($null -eq $release) { + Fail "Published manifest did not return a release: $manifestUrl" +} + +Assert-ManifestValue $manifest 'slug' $manifest.slug $slug +Assert-ManifestValue $release 'version' $release.version $version +Assert-ManifestValue $release 'channel' $release.channel $channel +Assert-ManifestValue $release 'platform' $release.platform $platform +Assert-ManifestValue $release 'packageKind' $release.packageKind $packageKind +Assert-ManifestValue $release 'originalFileName' $release.originalFileName $packageFileName +Assert-ManifestValue $release 'packageSizeBytes' $release.packageSizeBytes $localSize +Assert-ManifestValue $release 'sha256' $release.sha256 $localSha256 + +Write-Output 'QSfera Android Argus SSH publication completed.' +Write-Output "Manifest: $manifestUrl" +Write-Output "Size: $localSize" +Write-Output "SHA-256: $localSha256" diff --git a/PRODUCTION_READINESS.md b/PRODUCTION_READINESS.md index 471cc584..089ba8cc 100644 --- a/PRODUCTION_READINESS.md +++ b/PRODUCTION_READINESS.md @@ -24,7 +24,8 @@ Date: 2026-06-08. - Android release builds now fail fast when release signing variables are missing, so unsigned release artifacts cannot be published by accident. - Android version was raised to `1.3.12` with `versionCode` `40`. - Android version was raised to `1.3.13` with `versionCode` `41` for the next Argus publication. -- Android now has QSfera-specific Argus publishing scripts: local upload-keystore generation, signed APK staging, APK metadata verification, debug-certificate rejection, and Argus upload. +- Android now has QSfera-specific Argus publishing scripts: local upload-keystore generation, signed APK staging, APK metadata verification, debug-certificate rejection, and SSH-only Argus publication through the `/srv/argus-data/argus.db` and `/srv/argus-data/Packages` workflow required by `G:/Repos/Marketplace/ARGUS_PUBLICATION.md`; HTTP `/api/admin/*` publication is not used. +- Android Argus update and publication were realigned with `G:/Repos/Marketplace/ARGUS_PUBLICATION.md` on 2026-06-13: `Android/scripts/publish-release.ps1` now generates and runs the SSH/SQLite/Data/Packages server-side publication block, and `AppUpdateManager` uses only the public read-only manifest contract for update discovery. - Android now has `Android/scripts/verify-release-signing-material.ps1` to verify local release signing properties, keystore readability, release certificate SHA-256, and optionally the latest published Argus APK certificate without printing signing secrets. - Android Argus staging now enforces release git provenance by default: the work tree must be clean, the branch must have an upstream, `git fetch` must succeed, and `HEAD` must match `@{u}` before a stable APK is prepared for upload. The generated `argus-publication.json` records the verified repository root, branch, upstream, commit, and timestamp; current monorepo publication can verify the pushed root `QSfera.git` branch by running from `Android/` with `-GitProvenanceRoot ..`. - Android `1.3.9`/`37` was published to Argus as `qsfera-mobile` on 2026-06-10. Argus upload returned release id `53ecc24e-106b-43a5-a39e-ffacd72a43f8`, package size `12226572`, and SHA-256 `cbd2c25328e395ea4da4e0f582feac39dec18d2d47b3e105d344904d8a24f017`. @@ -64,6 +65,7 @@ Date: 2026-06-08. - Added `scripts/verify-production-state.ps1` as a repeatable live production endpoint gate for the public server status, Android Argus manifest/download, and Desktop update feed; the Android gate now pins the stable release id, original APK filename, content type, package size, download path, and SHA-256. - `scripts/verify-production-state.ps1 -VerifyAndroidDownload` passed on 2026-06-10: server `edition=stable`/`productversion=7.0.0`, Android `1.3.12`/`40`, downloaded APK size `12237552`, APK SHA-256 `279d00c6a43072caa0b26269f547fb0dc3268088aae5cd9f4ccfbbb485c35ded`, and valid Desktop no-update feed. - `scripts/verify-production-state.ps1 -VerifyAndroidDownload` passed on 2026-06-12: server `edition=stable`/`productversion=7.0.0`, Android release id `39072ada-7557-48f3-ab42-ad68703d00b0`, Android `1.3.13`/`41`, APK filename `qsfera-mobile-41.apk`, content type `application/vnd.android.package-archive`, downloaded APK size `12261408`, APK SHA-256 `f065e6584e6a09999e72ba0eeeaae903e93fb8980a0426e4b39b5e2366bf6611`, and valid Desktop no-update feed. +- `scripts/verify-production-state.ps1 -VerifyAndroidDownload` passed on 2026-06-13 against the current public Argus manifest contract: server `edition=stable`/`productversion=7.0.0`, Android release id `39072ada-7557-48f3-ab42-ad68703d00b0`, Android version `1.3.13`, APK filename `qsfera-mobile-41.apk`, content type `application/vnd.android.package-archive`, downloaded APK size `12261408`, APK SHA-256 `f065e6584e6a09999e72ba0eeeaae903e93fb8980a0426e4b39b5e2366bf6611`, and valid Desktop no-update feed. The live manifest currently omits `androidPackageName` and `androidVersionCode`; the verifier treats both as optional unless strict switches are set. - Added `Server/scripts/verify-rpi-production.sh`, declared a loopback-only Pi metrics port in `Server/docker-compose.rpi5.yml`, and expanded the Raspberry Pi Docker runbook with local/public status checks, container/image checks, Prometheus metrics verification, backup/restore drill steps, and Docker data-root/retention planning. - Added systemd monitoring units for the Pi: `Server/systemd/qsfera-rpi-production-check.service`, `Server/systemd/qsfera-rpi-production-check.timer`, `Server/systemd/qsfera-rpi-production-check-alert@.service`, `Server/systemd/rpi-production-check.env.example`, plus installer `Server/scripts/install-rpi-production-monitoring.sh` and alert helper `Server/scripts/alert-rpi-production-check-failure.sh`. - Deployed the Pi metrics/checker update on 2026-06-09. Before upload, `/mnt/data/qsfera/cloud-server/src/docker-compose.rpi5.yml` was backed up to `/mnt/data/qsfera/backups/docker-compose.rpi5.yml-20260609-181614.bak`; `docker compose -f docker-compose.rpi5.yml up -d` recreated and started `qsfera-cloud-server`; `docker compose ps` reported loopback ports `127.0.0.1:9200->9200/tcp` and `127.0.0.1:9205->9205/tcp`. @@ -84,11 +86,11 @@ Date: 2026-06-08. ## Android Argus Publication Check -- Current public Argus manifest for `https://argus.kusoft.xyz/api/apps/qsfera-mobile/manifest?platform=android&channel=stable` reports release id `39072ada-7557-48f3-ab42-ad68703d00b0`, version `1.3.13`, channel `stable`, platform `android`, package kind `apk`, `androidVersionCode` `41`, package size `12261408`, and SHA-256 `f065e6584e6a09999e72ba0eeeaae903e93fb8980a0426e4b39b5e2366bf6611`. +- Current public Argus manifest for `https://argus.kusoft.xyz/api/apps/qsfera-mobile/manifest?platform=android&channel=stable` reports release id `39072ada-7557-48f3-ab42-ad68703d00b0`, version `1.3.13`, channel `stable`, platform `android`, package kind `apk`, original file name `qsfera-mobile-41.apk`, content type `application/vnd.android.package-archive`, package size `12261408`, and SHA-256 `f065e6584e6a09999e72ba0eeeaae903e93fb8980a0426e4b39b5e2366bf6611`. It does not currently include legacy Android-specific root `androidPackageName` or release `androidVersionCode` fields. - Independent download verification of `https://argus.kusoft.xyz/api/apps/qsfera-mobile/download/latest?platform=android&channel=stable` produced a `12261408` byte APK with the same SHA-256 `f065e6584e6a09999e72ba0eeeaae903e93fb8980a0426e4b39b5e2366bf6611`. - The previously published `1.3.5`/`33` APK matched Argus SHA-256 `b7d5bdcb49527c00983a15849252fa42189dee4287754803c46cf2f32e2130f3`, but `apksigner` reported `CN=Android Debug` with certificate SHA-256 `b86d745cecb01ca24bda03092fe94ca7801678513b97edd1175bcabfdea4e941`. - The current production-signed `1.3.13`/`41` APK is signed by `CN=QSfera Argus Upload, O=QSfera, C=RU` with certificate SHA-256 `ea5c304c78ee333f6586f54c933d986c1f865da9c4e3595f8fe0df274c1ab877`. -- `AppUpdateManager` validates downloaded APK package name, greater `versionCode`, Argus `androidVersionCode`, and signer digest against the installed app signature. Because of that local code path, devices that installed debug-signed `1.3.5` should be treated as needing reinstall or an explicit migration plan before they can move to the current production-signed stable APK. +- `AppUpdateManager` follows the public read-only Argus manifest contract from `G:/Repos/Marketplace/ARGUS_PUBLICATION.md`: HTTP `404` is treated as no update, `release.version` is compared with the installed version, downloads use `ARGUS_BASE_URL + release.downloadPath`, `release.packageSizeBytes` and `release.sha256` are verified before install, and the downloaded APK package name/version/signature are checked against the installed app. Because of that local code path, devices that installed debug-signed `1.3.5` should be treated as needing reinstall or an explicit migration plan before they can move to the current production-signed stable APK. - The rollout policy for debug-signed `1.3.5` devices is documented in `Android/docs/argus-rollout-policy.md`: the app does not bypass signer mismatch; users must back up unsynced local files, uninstall the debug-signed build, then install the current stable APK from Argus. The Android app now shows a reinstall-required dialog for this certificate-mismatch path. ## Remaining P0 diff --git a/scripts/verify-production-state.ps1 b/scripts/verify-production-state.ps1 index 0a83ad3c..90c78f54 100644 --- a/scripts/verify-production-state.ps1 +++ b/scripts/verify-production-state.ps1 @@ -6,8 +6,10 @@ param( [string]$AndroidManifestUrl = "https://argus.kusoft.xyz/api/apps/qsfera-mobile/manifest?platform=android&channel=stable", [string]$ExpectedAndroidSlug = "qsfera-mobile", [string]$ExpectedAndroidPackageName = "eu.qsfera.android", + [switch]$RequireAndroidPackageName, [string]$ExpectedAndroidVersion = "1.3.13", [int]$ExpectedAndroidVersionCode = 41, + [switch]$RequireAndroidVersionCode, [string]$ExpectedAndroidChannel = "stable", [string]$ExpectedAndroidPlatform = "android", [string]$ExpectedAndroidPackageKind = "apk", @@ -108,7 +110,14 @@ Write-Host "Checking QSfera Android Argus manifest..." $manifestResponse = Invoke-CheckedWebRequest "Android Argus manifest" $AndroidManifestUrl $manifest = Convert-JsonContent "Android Argus manifest" $manifestResponse.Content Assert-Equal "Android app slug" (Get-RequiredProperty $manifest "slug" "Android manifest") $ExpectedAndroidSlug -Assert-Equal "Android package name" (Get-RequiredProperty $manifest "androidPackageName" "Android manifest") $ExpectedAndroidPackageName +$androidPackageNameProperty = $manifest.PSObject.Properties["androidPackageName"] +if ($null -ne $androidPackageNameProperty) { + Assert-Equal "Android package name" $androidPackageNameProperty.Value $ExpectedAndroidPackageName +} elseif ($RequireAndroidPackageName) { + Fail "Android manifest is missing 'androidPackageName'." +} else { + Write-Host "OK: Android package name is absent; public Argus contract does not require it" +} $release = Get-RequiredProperty $manifest "release" "Android manifest" if ($null -eq $release) { @@ -116,7 +125,14 @@ if ($null -eq $release) { } Assert-Equal "Android release version" (Get-RequiredProperty $release "version" "Android release") $ExpectedAndroidVersion -Assert-Equal "Android versionCode" (Get-RequiredProperty $release "androidVersionCode" "Android release") $ExpectedAndroidVersionCode +$androidVersionCodeProperty = $release.PSObject.Properties["androidVersionCode"] +if ($null -ne $androidVersionCodeProperty) { + Assert-Equal "Android versionCode" $androidVersionCodeProperty.Value $ExpectedAndroidVersionCode +} elseif ($RequireAndroidVersionCode) { + Fail "Android release is missing 'androidVersionCode'." +} else { + Write-Host "OK: Android versionCode is absent; public Argus contract does not require it" +} Assert-Equal "Android release channel" (Get-RequiredProperty $release "channel" "Android release") $ExpectedAndroidChannel Assert-Equal "Android release platform" (Get-RequiredProperty $release "platform" "Android release") $ExpectedAndroidPlatform Assert-Equal "Android package kind" (Get-RequiredProperty $release "packageKind" "Android release") $ExpectedAndroidPackageKind