Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 36 additions & 17 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,5 @@
# The same checks the canonical GitLab pipeline runs, for the public GitHub mirror.
#
# Kept deliberately in step with .gitlab-ci.yml: the core on Linux and on Windows, the Linux app in
# a job of its own because it is a separate cargo workspace that needs GTK, and the Windows app in
# one of its own because it is C# and needs a .NET SDK. If you change one, change the other — a
# mirror whose CI checks less than the original is worse than no mirror, because the green tick
# claims something it did not verify.
#
# One job runs here and not there, deliberately: `android`. GitHub's Linux runners ship the Android
# SDK and NDK preinstalled and GitLab's docker executor does not, so this is the only place the
# F-Droid build path can be exercised without standing up a new image. That path is otherwise
# untested by anything automatic, and it is the one a rejected submission would be traced back to.
# Core and native front-end checks. Apple packaging and the Linux SSH fixtures are required
# here because compiling alone cannot detect a shipped dylib path or a skipped live test.
name: CI

on:
Expand Down Expand Up @@ -44,10 +34,35 @@ jobs:
- run: cargo fmt --all -- --check
- run: cargo clippy --workspace --all-targets --locked -- -D warnings
- run: cargo build --workspace --locked
# SG_REQUIRE_FIXTURES is deliberately not set: the live SSH tests need the Docker fixtures,
# detect their absence and skip. Everything that does not need a real host still runs.
- name: Start the live SSH fixtures
if: runner.os == 'Linux'
run: |
./fixtures/up.sh
echo "SG_REQUIRE_FIXTURES=1" >> "$GITHUB_ENV"
# Windows runs the portable tests; Linux must also prove the one-round-trip SSH path.
- run: cargo test --workspace --locked

macos-app:
name: macOS app and native bridge
# The macOS 14 runner defaults to Swift 5.10; the package requires Swift 6.
runs-on: macos-15
env:
DEVELOPER_DIR: /Applications/Xcode_16.4.app/Contents/Developer
steps:
- uses: actions/checkout@v7
- name: Show the Apple toolchain
run: |
xcodebuild -version
swift --version
- name: Install Rust
run: |
rustup toolchain install ${{ env.RUST_VERSION }} --profile minimal
rustup default ${{ env.RUST_VERSION }}
- uses: Swatinem/rust-cache@v2
# Packaging asserts that the executable embeds the core, with no checkout-specific dylib.
- run: ./scripts/build-macos.sh
- run: swift test --package-path apps

linux-app:
name: Linux app
# 24.04 ships GTK 4.14 and libadwaita 1.5; the app needs 4.10 and 1.4 at least.
Expand All @@ -65,8 +80,12 @@ jobs:
- uses: Swatinem/rust-cache@v2
with:
workspaces: apps/linux
- name: Start the live SSH fixtures
run: ./fixtures/up.sh
- name: Check the app
working-directory: apps/linux
env:
SG_REQUIRE_FIXTURES: "1"
run: |
cargo fmt -- --check
cargo clippy --all-targets --locked -- -D warnings
Expand Down Expand Up @@ -120,15 +139,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v5
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: "21"
# Pinned, and installed by the action rather than taken from the runner image. There is no
# gradle-wrapper.jar in this repository on purpose — it is a committed binary, which is the
# one thing an F-Droid submission must not contain — so without this the build would run on
# whatever Gradle the image happened to ship that week. 9.7.0 is what the local build and the
# clean-clone dry run both used against AGP 8.9.0.
# whatever Gradle the image happened to ship that week. 9.7.0 is the version used by the
# local build against AGP 9.3.2.
- uses: gradle/actions/setup-gradle@v6
with:
gradle-version: "9.7.0"
Expand Down
14 changes: 7 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ hard way:
## Verifying

```bash
./scripts/check.sh # fmt, clippy, build, 272 tests
./scripts/check.sh # fmt, clippy, build, tests
./scripts/check.sh --all # plus Swift and Kotlin
.\scripts\check.ps1 -All # the same, on Windows, plus the WinUI app
./scripts/build-linux.sh --run # build and run the GTK app
Expand Down Expand Up @@ -101,12 +101,12 @@ The live tests need the fixtures: `./fixtures/up.sh` (Debian and Alpine sshd con
GNU-versus-busybox parsing differences). Without `SG_REQUIRE_FIXTURES=1` they skip themselves and
report `ok`, which has already hidden a broken container once.

**CI is GitLab (`.gitlab-ci.yml`, gitlab.lazarev.cloud) and runs the Rust jobs on Linux and
Windows, the Linux app on Linux, and the Windows app on Windows.** No macOS or Android runner is
configured, so the Swift and Kotlin suites are yours to run before pushing. The Windows jobs are a
shell runner that installs its own toolchain — `rust:windows` is the only thing checking that the
core compiles for a platform none of us develops on, and it earned that on its first run by
catching a Unix-only agent call.
**CI is defined in [`.github/workflows/ci.yml`](.github/workflows/ci.yml).** It runs the Rust
suite on Linux and Windows, builds and tests the Linux, Windows, macOS and Android front-ends,
and requires live SSH fixtures on Linux. The macOS build checks that the packaged executable
embeds the Rust core instead of loading a library from a developer checkout. The Swift tests
exercise that bridge. iOS still needs a local simulator build and launch check. Windows UI
behavior and DPAPI must be checked on Windows; cross-platform core tests cannot verify them.

Check the pipeline after pushing. It was left red for eight commits once, because tests passing
locally says nothing about `fmt` and `clippy`.
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ sg-sync = { path = "crates/sg-sync" }

anyhow = "1.0"
async-trait = "0.1"
russh = "0.62"
russh = "0.63"
# Pairing crypto. X25519 for the key agreement, ChaCha20-Poly1305 for the payload — both already
# pulled in transitively by russh, so this adds no new supply chain.
x25519-dalek = { version = "3.0", features = ["static_secrets"] }
Expand Down
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,9 +326,8 @@ was, which is exactly what a parser bug is made of. Both a GNU and a BusyBox hos

- Setup still assumes someone who knows what a hostname and an SSH key are. The *reading*
experience is written for a non-technical person; the *adding* experience is not yet.
- CI runs the Rust suite on Linux and on Windows, and builds and tests the Linux and Windows apps.
The Swift and Kotlin tests exist and pass locally, but no macOS or Android runner is configured
to run them.
- CI builds and tests the core and the Linux, Windows, macOS and Android front-ends. iOS still
needs local simulator verification, and native UI behavior needs a launch check on each platform.
- The Windows app cannot scan a pairing QR, because a desktop has no camera. It shows one to
receive an inventory and takes a pasted code to send one.
- The Linux app keeps a password or key passphrase in memory for the run rather than in the Secret
Expand Down
6 changes: 5 additions & 1 deletion apps/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@ let package = Package(
path: "shared/ServerGlassFFI",
sources: ["generated/sg_ffi.swift"],
linkerSettings: [
.unsafeFlags(["-L\(workspaceTarget)", "-lsg_ffi"]),
// `-lsg_ffi` prefers the dylib Cargo also emits. Its install name contains the
// build machine's absolute path: an installed app then loads whatever a later
// build put there, including an incompatible UniFFI record layout. Link the
// archive explicitly so the bindings and their core ship as one executable.
.unsafeFlags(["\(workspaceTarget)/libsg_ffi.a"]),
// russh's crypto backend and the keychain reach into these.
.linkedFramework("Security"),
.linkedFramework("CoreFoundation"),
Expand Down
12 changes: 4 additions & 8 deletions apps/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}

android {
namespace = "cloud.lazarev.serverglass"
compileSdk = 35
compileSdk = 37

defaultConfig {
applicationId = "cloud.lazarev.serverglass"
Expand Down Expand Up @@ -42,18 +41,15 @@ android {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
compose = true
}

sourceSets["main"].apply {
// The Rust .so, placed here by cargo-ndk via scripts/build-android.sh.
jniLibs.srcDir("src/main/jniLibs")
jniLibs.directories.add("src/main/jniLibs")
// The generated UniFFI bindings, kept out of the hand-written source tree.
java.srcDir("build/generated/uniffi")
kotlin.directories.add("build/generated/uniffi")
}

packaging {
Expand Down Expand Up @@ -86,7 +82,7 @@ dependencies {
// The record format is pure Kotlin plus org.json, so it is tested on the JVM rather than on a
// device: a test that needs an emulator is a test that does not run.
testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20260719")
testImplementation("org.json:json:20260814")

// Passwords and passphrases, encrypted with a key held in the Android Keystore.
implementation("androidx.security:security-crypto:1.1.0")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ fun AddServerDialog(
"key_text" -> keyText.isNotBlank()
else -> secret.isNotBlank()
}
val valid = address.isNotBlank() && user.isNotBlank() && port.toUShortOrNull() != null &&
val valid = address.isNotBlank() && user.isNotBlank() && (port.toIntOrNull() ?: 0) in 1..65535 &&
(credentialGiven || isEditing)

Dialog(
Expand All @@ -128,14 +128,11 @@ fun AddServerDialog(
color = Theme.secondary,
fontSize = 13.sp,
)
if (!model.secretsAreHardwareBacked) {
// Computed since the vault landed and shown nowhere, which made a real weakening
// of the storage invisible: on a device whose Keystore is unavailable the password
// is kept like any ordinary app setting.
if (!model.secureStorageAvailable) {
Spacer(Modifier.height(12.dp))
Text(
"This device's secure storage is unavailable, so a password or key is saved " +
"like any other app setting rather than encrypted. Consider a key file.",
"This device's secure storage is unavailable. Passwords and pasted keys " +
"cannot be saved. You can use a key file without a passphrase.",
color = Theme.warn,
fontSize = 11.5.sp,
)
Expand Down Expand Up @@ -273,7 +270,7 @@ fun AddServerDialog(
val path = if (authKind == "key") keyPath.trim() else null
val key = if (authKind == "key_text") keyText.trim() else null

if (editing != null) {
val succeeded = if (editing != null) {
model.updateHost(
id = editing,
address = address.trim(),
Expand All @@ -300,7 +297,7 @@ fun AddServerDialog(
trustOnFirstUse = trustOnFirstUse,
)
}
onDismiss()
if (succeeded) onDismiss()
},
enabled = valid,
modifier = Modifier.weight(1f),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,37 +38,40 @@ class HostStore(context: Context) {
private val config: SharedPreferences =
context.getSharedPreferences("sg.hosts", Context.MODE_PRIVATE)

/**
* Falls back to plain preferences if the Keystore is unavailable.
*
* `EncryptedSharedPreferences` can fail on devices with a broken or wiped Keystore, and on some
* emulators. Refusing to run at all would be worse than storing a secret the way any ordinary
* app setting is stored — but the distinction is recorded so the UI can say which happened
* rather than implying protection it does not have.
*/
var secretsAreHardwareBacked: Boolean = true
private set

private val secrets: SharedPreferences = try {
// A broken vault must never silently turn passwords and private keys into plain preferences.
private val secrets: SharedPreferences? = try {
val masterKey = MasterKey.Builder(context)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
EncryptedSharedPreferences.create(
val encrypted = EncryptedSharedPreferences.create(
context,
"sg.secrets",
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
// Upgrade the old fallback only after a durable encrypted write. Until then the original
// remains recoverable; an unavailable Keystore must not erase someone's credentials.
val legacy = context.getSharedPreferences("sg.secrets.plain", Context.MODE_PRIVATE)
if (legacy.all.isNotEmpty()) {
val edit = encrypted.edit()
legacy.all.forEach { (key, value) ->
if (value is String && !encrypted.contains(key)) edit.putString(key, value)
}
check(edit.commit()) { "The sign-in details could not be moved to secure storage." }
check(legacy.edit().clear().commit()) { "The old sign-in storage could not be cleared." }
}
encrypted
} catch (_: Exception) {
secretsAreHardwareBacked = false
context.getSharedPreferences("sg.secrets.plain", Context.MODE_PRIVATE)
null
}

fun load(): List<SavedHost> = decode(config.getString(KEY, null))
val secureStorageAvailable: Boolean get() = secrets != null

fun load(): List<SavedHost> = decodeChecked(config.getString(KEY, null))

fun save(hosts: List<SavedHost>) {
config.edit().putString(KEY, encode(hosts)).apply()
check(config.edit().putString(KEY, encode(hosts)).commit()) { "The server list could not be saved." }
}

/** Which secret. A host can have both — a pasted key *and* the passphrase protecting it. */
Expand All @@ -78,13 +81,20 @@ class HostStore(context: Context) {
}

fun secret(id: String, kind: Kind = Kind.PASSWORD): String? =
secrets.getString(id + kind.suffix, null)?.ifEmpty { null }

fun setSecret(id: String, secret: String?, kind: Kind = Kind.PASSWORD) {
val account = id + kind.suffix
secrets.edit().apply {
if (secret.isNullOrEmpty()) remove(account) else putString(account, secret)
}.apply()
secrets?.getString(id + kind.suffix, null)?.ifEmpty { null }

fun setSecrets(id: String, secret: String?, keyText: String?) {
if (secret == null && keyText == null) return
val vault = checkNotNull(secrets) { "This device's secure storage is unavailable. The sign-in details were not saved." }
val edit = vault.edit()
fun put(account: String, value: String?) {
if (value != null) {
if (value.isEmpty()) edit.remove(account) else edit.putString(account, value)
}
}
put(id, secret)
put(id + Kind.KEY_TEXT.suffix, keyText)
check(edit.commit()) { "The sign-in details could not be saved." }
}

/**
Expand All @@ -102,7 +112,7 @@ class HostStore(context: Context) {
/** Remove a host's stored record and its secret together. */
fun forget(id: String) {
save(load().filterNot { it.id == id })
secrets.edit().remove(id).remove(id + Kind.KEY_TEXT.suffix).apply()
secrets?.edit()?.remove(id)?.remove(id + Kind.KEY_TEXT.suffix)?.apply()
}

companion object {
Expand Down Expand Up @@ -138,27 +148,30 @@ class HostStore(context: Context) {
/**
* Anything unreadable yields an empty list rather than a crash.
*
* A record written by a future version, or a file truncated by a device losing power
* mid-write, must cost the user their list — not the ability to open the app at all.
* For previews and callers that only need a best-effort list. Persistence uses
* decodeChecked so a damaged inventory is reported and cannot be silently overwritten.
*/
fun decode(raw: String?): List<SavedHost> {
fun decode(raw: String?): List<SavedHost> = runCatching { decodeChecked(raw) }.getOrDefault(emptyList())

internal fun decodeChecked(raw: String?): List<SavedHost> {
if (raw.isNullOrEmpty()) return emptyList()
return runCatching {
val array = JSONArray(raw)
(0 until array.length()).map { index ->
val o = array.getJSONObject(index)
SavedHost(
id = o.getString("id"),
address = o.getString("address"),
port = o.getInt("port").toUShort(),
user = o.getString("user"),
authKind = o.getString("authKind"),
keyPath = o.optString("keyPath").ifEmpty { null },
hostKeyPolicy = o.getString("hostKeyPolicy"),
refreshMs = o.getLong("refreshMs").toULong(),
)
}
}.getOrDefault(emptyList())
val array = JSONArray(raw)
return (0 until array.length()).map { index ->
val o = array.getJSONObject(index)
val port = o.getInt("port")
val refresh = o.getLong("refreshMs")
require(port in 1..65535 && refresh > 0) { "Invalid saved connection settings" }
SavedHost(
id = o.getString("id"),
address = o.getString("address"),
port = port.toUShort(),
user = o.getString("user"),
authKind = o.getString("authKind"),
keyPath = o.optString("keyPath").ifEmpty { null },
hostKeyPolicy = o.getString("hostKeyPolicy"),
refreshMs = refresh.toULong(),
)
}
}
}
}
Loading
Loading