diff --git a/.github/scripts/need-packages.sh b/.github/scripts/need-packages.sh index 280e6e0..93300bb 100755 --- a/.github/scripts/need-packages.sh +++ b/.github/scripts/need-packages.sh @@ -20,8 +20,10 @@ for spec in "$@"; do installed "$spec" || missing+=("$spec") done [ ${#missing[@]} -eq 0 ] && exit 0 -apt="sudo apt-get -o DPkg::Lock::Timeout=600" -$apt update -q +apt="sudo apt-get -o DPkg::Lock::Timeout=600 -o Acquire::Retries=3" +# A mirror that stalls mid-download never times out by itself (it held a job for an hour once): +# an update gets 10 minutes, and a second try. +timeout 600 $apt update -q || timeout 600 $apt update -q install=() for spec in "${missing[@]}"; do IFS='|' read -ra names <<<"$spec" @@ -35,4 +37,4 @@ for spec in "${missing[@]}"; do install+=("$pick") done echo "Installing: ${install[*]}" -$apt install -yq --no-install-recommends "${install[@]}" +timeout 1200 $apt install -yq --no-install-recommends "${install[@]}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7825d1a..2a856a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,11 +6,17 @@ name: CI # macOS and Windows builds, the slow ones, only run for pull requests into main and manual runs. # The branch rules require these checks by name: "Lint, test, live SSH and VPN", "End to end # (Linux)", "Android build", "Desktop" and "Signed off"; renaming a job means changing the -# rules too. +# rules too. "Android monkey" isn't required: a random walk can find a bug the pull request +# didn't make. on: pull_request: branches: [develop, main] workflow_dispatch: + inputs: + monkey_seed: + description: "Android monkey: the seed to repeat (empty for a random one)" + required: false + default: "" concurrency: group: ci-${{ github.ref }} @@ -303,3 +309,86 @@ jobs: version=$(sed -n 's/^version = "\(.*\)"/\1/p' ../../Cargo.toml | head -1) npx tauri android build --apk --target aarch64 --config "{\"version\":\"$version\"}" + # Android's monkey on the app in an emulator, kept basic: 3 rounds of 500 random touches, + # swipes, turns and keys, each from a fresh start, over a vault with hosts, keys and a snippet. A + # crash, a Rust panic, an error the page throws or an app that stops drawing fails it. The + # emulator draws in software, a frame taking seconds, so ANRs are noted rather than failing it, + # and the screen is smaller (720 × 1600, as wide in dp as a Pixel 6). The seed is in the log (Run + # workflow takes one to repeat). A debug build, whose WebView can be driven: + # apps/desktop/e2e/README.md. + android-monkey: + name: Android monkey + runs-on: ubuntu-24.04 + timeout-minutes: 75 + steps: + - uses: actions/checkout@v7 + + - name: C compiler + run: .github/scripts/need-packages.sh build-essential pkg-config unzip + + - uses: actions/setup-java@v6 + with: + distribution: temurin + java-version: 21 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-linux-android + + - uses: Swatinem/rust-cache@v2 + + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: npm + cache-dependency-path: apps/desktop/package-lock.json + + - name: Android NDK + run: echo "NDK_HOME=$ANDROID_NDK_LATEST_HOME" >>"$GITHUB_ENV" + + # For the emulator's processor. Line numbers are all a panic needs from debug info, and + # keep the APK small enough to install. + - name: Build + working-directory: apps/desktop + env: + CARGO_PROFILE_DEV_DEBUG: line-tables-only + run: | + npm ci + npx tauri android build --debug --apk --target x86_64 + + - name: KVM for the emulator + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Run + uses: reactivecircus/android-emulator-runner@v2 + env: + MONKEY_SEED: ${{ inputs.monkey_seed }} + MONKEY_ROUNDS: 3 + MONKEY_EVENTS: 500 + MONKEY_ANR: note + with: + api-level: 35 + target: google_apis + arch: x86_64 + profile: pixel_6 + cores: 4 + ram-size: 4096M + disk-size: 6000M + disable-animations: true + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-back none + script: | + "$ANDROID_HOME/platform-tools/adb" shell wm size 720x1600 + "$ANDROID_HOME/platform-tools/adb" shell wm density 280 + ADB="$ANDROID_HOME/platform-tools/adb" node apps/desktop/e2e/android-monkey.mjs apps/desktop/src-tauri/gen/android/app/build/outputs/apk/universal/debug/app-universal-debug.apk target/e2e/android-monkey + + - name: Screenshots and logs + if: always() + uses: actions/upload-artifact@v7 + with: + name: android-monkey + path: target/e2e/android-monkey + retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5ffc82e..fb31beb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -241,6 +241,8 @@ jobs: # The signing itself authenticates from the AZURE_* variables; this # `az` lets the login through. And Tauri shows only the stderr of a sign command that # fails, while the signing library says why on stdout: sign.cmd sends it to stderr. + # Signing needs Microsoft's timestamp server, which doesn't always answer: sign.cmd tries + # four times, 20 seconds apart, rather than fail the build. - name: Windows signing setup if: runner.os == 'Windows' && vars.ARTIFACT_SIGNING_PROFILE != '' shell: pwsh @@ -252,8 +254,16 @@ jobs: $az = "$env:RUNNER_TEMP\az-login.cmd" Set-Content -Path $az -Encoding ascii -Value '@call "C:\Program Files\Microsoft SDKs\Azure\CLI2\wbin\az.cmd" %* --allow-no-subscriptions' "AZURE_CLI_PATH=$az" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 - Set-Content -Path "$env:RUNNER_TEMP\sign.cmd" -Encoding ascii ` - -Value "@artifact-signing-cli -e $env:ENDPOINT -a $env:ACCOUNT -c $env:CERT_PROFILE -d MorphGate %* 1>&2" + $cli = "artifact-signing-cli -e $env:ENDPOINT -a $env:ACCOUNT -c $env:CERT_PROFILE -d MorphGate %* 1>&2" + Set-Content -Path "$env:RUNNER_TEMP\sign.cmd" -Encoding ascii -Value @( + '@echo off', + 'for /L %%i in (1,1,4) do (', + " $cli && exit /b 0", + ' ping -n 21 127.0.0.1 >nul', + ')', + 'exit /b 1' + ) + Get-Content "$env:RUNNER_TEMP\sign.cmd" - uses: actions/setup-dotnet@v6 if: runner.os == 'Windows' && vars.ARTIFACT_SIGNING_PROFILE != '' @@ -369,37 +379,14 @@ jobs: } if ($files.Count -lt 3 -or $bad) { Write-Error "$bad of $($files.Count) files aren't signed and timestamped"; exit 1 } - # Tauri notarizes the app; the DMG it's downloaded in is notarized here too (Apple's advice: - # the outermost container), stapled, and put in the release in place of the one uploaded. - # Updates use the .app.tar.gz, which this leaves alone. - - name: Notarize the DMG - if: runner.os == 'macOS' && env.APPLE_API_KEY != '' - shell: bash - env: - GH_TOKEN: ${{ github.token }} - RELEASE_ID: ${{ needs.plan.outputs.release_id }} - run: | - dmg=$(ls target/universal-apple-darwin/release/bundle/dmg/*.dmg | head -1) - status=$(xcrun notarytool submit "$dmg" --key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY" \ - --issuer "$APPLE_API_ISSUER" --wait --output-format json | jq -r .status) - [ "$status" = Accepted ] || { echo "::error::notarizing the DMG: $status"; exit 1; } - xcrun stapler staple "$dmg" - asset=$(gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets" --paginate \ - --jq '.[] | select(.name | endswith(".dmg")) | "\(.id) \(.name)"' | head -1) - [ -n "$asset" ] || { echo "::error::the release has no DMG to replace"; exit 1; } - gh api -X DELETE "repos/$GITHUB_REPOSITORY/releases/assets/${asset%% *}" - gh api -X POST -H "Content-Type: application/x-apple-diskimage" \ - "https://uploads.github.com/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=${asset#* }" \ - --input "$dmg" --jq .browser_download_url - - # The proof, in the log: signed with the Developer ID (hardened runtime, secure timestamp), - # notarized with the ticket stapled, and accepted by Gatekeeper as a downloaded app would be. + # The proof, in the log: the app signed with the Developer ID (hardened runtime, secure + # timestamp), notarized by Tauri with the ticket stapled, and accepted by Gatekeeper as a + # downloaded app would be. The DMG is the macOS notarize job's. - name: Check the macOS signature if: runner.os == 'macOS' && env.APPLE_SIGNING_IDENTITY != '' shell: bash run: | - bundle=target/universal-apple-darwin/release/bundle - app=$(ls -d "$bundle"/macos/*.app | head -1) + app=$(ls -d target/universal-apple-darwin/release/bundle/macos/*.app | head -1) codesign --verify --deep --strict --verbose=2 "$app" codesign -dv --verbose=4 "$app" 2>&1 | grep -E "^(Identifier|Authority|TeamIdentifier|Timestamp|CodeDirectory)" plutil -p "$app/Contents/Info.plist" | grep -E "CFBundleIdentifier|NSLocalNetworkUsageDescription|LSMinimumSystemVersion" @@ -407,14 +394,77 @@ jobs: xcrun stapler validate "$app" spctl --assess --type execute --verbose=4 "$app" fi - for dmg in "$bundle"/dmg/*.dmg; do - codesign -dv --verbose=2 "$dmg" 2>&1 | grep -E "^Authority" - if [ -n "${APPLE_API_KEY:-}" ]; then - xcrun stapler validate "$dmg" - spctl --assess --type open --context context:primary-signature --verbose=4 "$dmg" - fi + + # The DMG goes on to the macOS notarize job, as the iOS app goes on to TestFlight. + - uses: actions/upload-artifact@v7 + if: runner.os == 'macOS' && env.APPLE_API_KEY != '' + with: + name: macos-dmg + path: target/universal-apple-darwin/release/bundle/dmg/*.dmg + retention-days: 7 + if-no-files-found: error + + # The DMG the app is downloaded in, notarized too (Apple's advice: the outermost container), + # stapled, checked and put in the release in place of the one the build uploaded. Updates use + # the .app.tar.gz, which this leaves alone. Apart from the build because this is the part that + # talks to Apple's services, which drop a connection now and then: re-running it takes a + # minute or two, not a new build. + macos-notarize: + name: macOS notarize + needs: [plan, desktop] + if: >- + needs.plan.outputs.build == 'true' && !cancelled() && vars.APPLE_API_KEY != '' + && contains(needs.plan.outputs.matrix, 'macos') + runs-on: macos-15 + timeout-minutes: 45 + steps: + - uses: actions/download-artifact@v8 + with: + name: macos-dmg + path: ${{ runner.temp }}/dmg + + - name: Notarize and staple + env: + API_KEY: ${{ vars.APPLE_API_KEY }} + API_ISSUER: ${{ vars.APPLE_API_ISSUER }} + API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + run: | + dmg=$(ls "$RUNNER_TEMP"/dmg/*.dmg | head -1) + key="$RUNNER_TEMP/AuthKey_$API_KEY.p8" + printf '%s\n' "$API_KEY_P8" > "$key" + status=$(xcrun notarytool submit "$dmg" --key "$key" --key-id "$API_KEY" \ + --issuer "$API_ISSUER" --wait --output-format json | jq -r .status) + [ "$status" = Accepted ] || { echo "::error::notarizing the DMG: $status"; exit 1; } + # Stapling fetches the ticket from Apple's servers: tried a few times. + for i in 1 2 3 4 5; do + xcrun stapler staple "$dmg" && break + [ "$i" = 5 ] && { echo "::error::stapling the DMG failed 5 times"; exit 1; } + sleep $((i * 20)) done + # The proof, in the log: signed, notarized with the ticket stapled, and accepted by + # Gatekeeper as a downloaded disk image would be. + - name: Check the DMG + run: | + dmg=$(ls "$RUNNER_TEMP"/dmg/*.dmg | head -1) + codesign -dv --verbose=2 "$dmg" 2>&1 | grep -E "^Authority" + xcrun stapler validate "$dmg" + spctl --assess --type open --context context:primary-signature --verbose=4 "$dmg" + + - name: Put it in the release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_ID: ${{ needs.plan.outputs.release_id }} + run: | + dmg=$(ls "$RUNNER_TEMP"/dmg/*.dmg | head -1) + asset=$(gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets" --paginate \ + --jq '.[] | select(.name | endswith(".dmg")) | "\(.id) \(.name)"' | head -1) + [ -n "$asset" ] || { echo "::error::the release has no DMG to replace"; exit 1; } + gh api -X DELETE "repos/$GITHUB_REPOSITORY/releases/assets/${asset%% *}" + gh api -X POST -H "Content-Type: application/x-apple-diskimage" \ + "https://uploads.github.com/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=${asset#* }" \ + --input "$dmg" --jq .browser_download_url + android: name: Android needs: plan diff --git a/CHANGELOG.md b/CHANGELOG.md index bbfe200..d696dd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,46 @@ Each `## X.Y.Z` section becomes the notes of that release, on GitHub and in the app's update prompt. Write them for users. +## 0.2.1 + +- The first screen offers New vault, New account and Sign in. New account creates your vault and + your sync account in one go. +- An invite to a team opened before you've set MorphGate up shows on its first screen, with your + address filled in, and once your account exists Teams opens with the invite. You can also paste + an invite there (Joining a team?). An invite opened while sync is off opens as soon as you turn + sync on: no need to open the link again. +- A new vault no longer asks at once whether to turn on sync (the first screen offers New account + and Sign in). If sync is still off a week later, MorphGate asks once. +- MorphGate can suggest a strong master password: five random words, wherever you set one. +- With saved hosts, MorphGate opens on your Hosts page, with Quick connect beside New host. +- Hosts, groups, keys, snippets, tunnels and VPNs are tables you can search and filter, each row's + actions shown as you point at it. Hosts show whether they're connected instead of a letter, each + group says what it gives its hosts (which sit indented under it), and the sidebar lists your + open sessions first. Keys, identities and host CAs are tabs of the Keys page. Session logs can + be searched and narrowed to a vault, group or host. +- A team's page starts with what needs you (a member to confirm, a device to approve, a key to + rotate), then has tabs for its members, vaults, your SSH keys, settings and activity. New vault + and the plan sit beside Invite people, and you can invite several people at once. Teams says + per team what's waiting for you. +- When a team requires two-step verification and your account doesn't have it, MorphGate says so + and takes you to turning it on, instead of the team's vaults silently not appearing. +- Sync: every change now carries a version inside its signed envelope, and deletions are signed, + so a sync server can't give a device an older copy of an item than one it has seen. Older + versions of the app keep syncing alongside this one. +- Install and restart waits for an update check that's still running, so it installs the update + that check found. +- Feeds: hosts and snippets from an address your team publishes (Hosts → Add from… → Feed URL…), + read with a token. They stay in step with it on their own, every 15 minutes; your renames, keys, + tags and notes stay yours. A feed can list each server's host key, so new servers connect + without asking you to trust them, and a server with another key is refused. Its snippets are + read-only, and when the feed changes one, Run and Paste show you the new commands first. +- Two devices refreshing the same cloud source at once no longer make duplicate hosts or + "(conflict)" copies. +- About links to the documentation (morphgate.com/docs) and the source code on GitHub. +- Copy buttons (a public key, the recovery key, an invite code, host key details) fall back to + another way of copying when the system refuses the first, and say "Couldn't copy" instead of + doing nothing when neither works. + ## 0.2.0 First release. diff --git a/Cargo.lock b/Cargo.lock index 66f4688..e574ae6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3346,7 +3346,7 @@ dependencies = [ [[package]] name = "mg-inventory" -version = "0.2.0" +version = "0.2.1" dependencies = [ "reqwest", "serde", @@ -3359,7 +3359,7 @@ dependencies = [ [[package]] name = "mg-presence" -version = "0.2.0" +version = "0.2.1" dependencies = [ "block2", "objc2", @@ -3375,7 +3375,7 @@ dependencies = [ [[package]] name = "mg-ssh" -version = "0.2.0" +version = "0.2.1" dependencies = [ "getrandom 0.4.3", "russh", @@ -3390,13 +3390,14 @@ dependencies = [ [[package]] name = "mg-store" -version = "0.2.0" +version = "0.2.1" dependencies = [ "mg-sync", "mg-vault", "rusqlite", "serde", "serde_json", + "sha2 0.11.1", "tempfile", "thiserror 2.0.21", "tokio", @@ -3406,7 +3407,7 @@ dependencies = [ [[package]] name = "mg-sync" -version = "0.2.0" +version = "0.2.1" dependencies = [ "base64 0.23.1", "reqwest", @@ -3419,7 +3420,7 @@ dependencies = [ [[package]] name = "mg-vault" -version = "0.2.0" +version = "0.2.1" dependencies = [ "argon2", "chacha20poly1305 0.11.0", @@ -3434,7 +3435,7 @@ dependencies = [ [[package]] name = "mg-vpn" -version = "0.2.0" +version = "0.2.1" dependencies = [ "aes", "aws-lc-rs", @@ -3543,11 +3544,12 @@ dependencies = [ [[package]] name = "morphgate-desktop" -version = "0.2.0" +version = "0.2.1" dependencies = [ "apple-native-keyring-store", "base64 0.23.1", "flate2", + "getrandom 0.4.3", "jni 0.22.4", "keyring", "libc", @@ -6987,7 +6989,7 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "vault-conform" -version = "0.2.0" +version = "0.2.1" dependencies = [ "axum", "hmac 0.13.0", @@ -7007,7 +7009,7 @@ dependencies = [ [[package]] name = "vault-sync-server" -version = "0.2.0" +version = "0.2.1" dependencies = [ "argon2", "aws-lc-rs", diff --git a/Cargo.toml b/Cargo.toml index bfa80aa..5a65dde 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/mg-vault", "crates/mg-ssh", "crates/mg-store", "crates/mg-sync", "crates/mg-vpn", "crates/mg-presence", "crates/mg-inventory", "server", "apps/desktop/src-tauri", "tools/vault-conform"] [workspace.package] -version = "0.2.0" +version = "0.2.1" edition = "2024" rust-version = "1.91" diff --git a/README.md b/README.md index 656443a..1dc998b 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,13 @@ encrypted sync you can self-host. - **Sync**: `vault-sync/v1`, open OpenAPI contract — [`spec/openapi.yaml`](spec/openapi.yaml); client `crates/mg-sync`, self-hostable reference server [`server/`](server) -Docs: [developer docs](docs/README.md) · [sync & crypto spec](docs/sync-crypto-spec.md) · [roadmap](ROADMAP.md) · [releasing](RELEASING.md) · +Docs: [developer docs](docs/README.md) · [sync & crypto spec](docs/sync-crypto-spec.md) · [self-host](https://morphgate.com/docs/self-hosting) · [roadmap](ROADMAP.md) · [releasing](RELEASING.md) · [changelog](CHANGELOG.md) +

+ MorphGate: the Hosts page with hosts in groups; a click on web-1 opens its session, where uptime, git log and ls run +

+ ## Install Download from the [Releases](../../releases) page: diff --git a/RELEASING.md b/RELEASING.md index 0381420..3b939a0 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -113,10 +113,13 @@ Updates are signed regardless (see above). - variables `APPLE_SIGNING_IDENTITY` (`Developer ID Application: Vallic d.o.o. (988D7A4F4L)`), `APPLE_API_KEY` (the key's ID), `APPLE_API_ISSUER` (App Store Connect → Users and Access → Integrations → App Store Connect API: the Issuer ID). - Without the API key, builds are signed but not notarized. Tauri notarizes the app; the - step "Notarize the DMG" then notarizes the DMG it comes in too and replaces the release's - copy with the stapled one. "Check the macOS signature" shows the signing authority, the - stapled tickets and Gatekeeper's verdict on both, and fails the build where they're wrong. + Without the API key, builds are signed but not notarized. Tauri notarizes the app, and "Check + the macOS signature" shows its signing authority, stapled ticket and Gatekeeper's verdict. The + DMG goes on as an artifact to the **macOS notarize** job, which notarizes it too, staples it + (tried five times: Apple's ticket service drops connections), checks it the same way and puts + it in the release in place of the one the build uploaded. That job only talks to Apple, so + when it fails, re-running it takes a minute or two instead of a new build. Either check fails + the run where something is wrong. A dev build with *platforms* `macos` is the cheapest check of a change to any of this. The certificate was made from a CSR on Linux (`openssl req -newkey rsa:2048`); make a new one before it expires @@ -144,8 +147,10 @@ Updates are signed regardless (see above). valid after. The validation expires: its date is on the portal's Identity validation page, it can be renewed there from 60 days before, and Microsoft mails a reminder. Once it lapses, certificates stop renewing and signing stops (a new validation then needs the profile - replaced, same name). "Check the Windows signature" after the build shows each file's signer - and timestamp, and fails the build where one is missing. + replaced, same name). Signing needs Microsoft's timestamp server, which doesn't always + answer: the sign command tries each file four times, 20 seconds apart. "Check the Windows + signature" after the build shows each file's signer and timestamp, and fails the build where + one is missing. ## iOS (TestFlight) @@ -153,9 +158,11 @@ The release workflow builds the iOS app and uploads it to TestFlight: on every p (`main`), and for a dev build when a manual run asks for *platforms* `ios` or `all`. The **iOS** job (macOS runner) builds and signs the .ipa; the **TestFlight** job (Linux) uploads it with `scripts/upload-ios.py`, through the App Store Connect API, and waits until Apple has processed -it. The version is the one in `Cargo.toml` (a dev build's too, without its `-`: App Store -versions are at most three numbers), and the build number is the release workflow's: its run -number plus `RUN_NUMBER_OFFSET` (above). A build uploaded by hand needs a number above +it. Requests Apple drops are tried again, and a build App Store Connect has already (an earlier +attempt that got through though its answer didn't) counts as uploaded, so re-running the job is +always safe. The version is the one in `Cargo.toml` (a dev build's too, without its `-`: +App Store versions are at most three numbers), and the build number is the release workflow's: +its run number plus `RUN_NUMBER_OFFSET` (above). A build uploaded by hand needs a number above the last one. It needs the secrets `IOS_CERTIFICATE` (an Apple Distribution certificate with its key, a .p12 in diff --git a/ROADMAP.md b/ROADMAP.md index a39e1a5..c1a792c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -40,11 +40,19 @@ xterm.js in the webview avoids writing a terminal renderer. - [x] Terminal appearance: built-in themes plus themes imported from Ghostty, Alacritty, iTerm2 and Windows Terminal files (vault items, synced), a theme per host, and this device's font, size and cursor - [x] Jump hosts (ProxyJump chains via direct-tcpip, per-hop host-key pinning) +- [x] Cloud sources: hosts kept in step with Vallic Cloud (its API), AWS, Google Cloud, Azure, Hetzner Cloud and + DigitalOcean (their CLIs), and feeds: hosts and snippets from a JSON document a team publishes at a URL, + read with a token, refreshed every 15 minutes, with the host keys it lists trusted for its hosts - [x] Port forwarding: local / remote / dynamic SOCKS5 tunnels saved in the vault, start/stop from the sidebar - [x] SFTP tabs (`russh-sftp`): local + remote panes, drag and drop (also from the OS), recursive upload/download with progress, cancel and replace, mkdir/rename/chmod/delete - [x] `mg-sync`: vault-sync/v1 client (prelogin/register/login, token rotation, pull/push) - [x] Sync engine in `mg-store`: paginated pull, rollback guard, conflict copies, sealed refresh token +- [x] Item history: every write carries a version inside its signed envelope, deletions are signed, and + a device refuses a copy of an item older than one it has seen (servers keep the signed deletion: + `signed_tombstones`) +- [ ] Strict item history on by default (refusing unversioned changes, which only apps before item history + send), and signed vault checkpoints for devices with no copy to compare - [x] Reference sync server (`server/`, axum + SQLite) with protocol + two-device e2e tests - [x] Desktop sync: enable from an existing vault, sign in on a fresh device, background sync, disconnect - [x] Password change / recovery / new recovery key while synced (server first, other devices sign in again diff --git a/apps/desktop/e2e/README.md b/apps/desktop/e2e/README.md index cfad6aa..4e9d60b 100644 --- a/apps/desktop/e2e/README.md +++ b/apps/desktop/e2e/README.md @@ -4,10 +4,12 @@ These run the real AppImage, deb and rpm binaries, not a dev build, and drive th | Scenario | What it checks | | --- | --- | -| `appimage.mjs` | Creates a vault; SSHes to a throwaway local sshd and runs a command; imports two hosts and their key from a `~/.ssh/config` (one behind the other as its jump host) and connects through it; imports a PuTTY session with a new key in a `.ppk` file, a Termius-style CSV file with a group and a Royal TS document, and signs in with the PuTTY key; connects Vallic Cloud through a stand-in console (`vallic-console.mjs`: device sign-in, rotating tokens, an environment that is this sshd) and signs in to its host; signs in with a key that only has a certificate from the run's user CA (which sshd trusts), from the vault and from an agent; reaches a host by a command its jump host runs (with a login command instead of the shell), and another by a proxy command on this computer (both the relay in `crates/mg-ssh/tests/relay.py`); selects two hosts, moves them into a group and deletes them; signs in to a host with one key from an `ssh-agent` (its own socket) and forwards it (`ssh-add -l` on the host lists it); splits a tab into three panes (beside and below, one through a jump host), broadcasts a command to all of them and then types into one, drags a border and closes them; opens a `morphgate://connect` link from a second launch (which hands it over and exits) and connects from the filled-in quick connect, and refuses an unknown link; refuses a tampered update twice without touching the AppImage; installs the signed update, restarts, unlocks the same vault as the new version, and finds nothing newer | +| `appimage.mjs` | Creates a vault, which isn't offered sync then but is a week later (the page reloaded with the time moved on), once; SSHes to a throwaway local sshd and runs a command; imports two hosts and their key from a `~/.ssh/config` (one behind the other as its jump host) and connects through it; imports a PuTTY session with a new key in a `.ppk` file, a Termius-style CSV file with a group and a Royal TS document, and signs in with the PuTTY key; connects Vallic Cloud through a stand-in console (`vallic-console.mjs`: device sign-in, rotating tokens, an environment that is this sshd) and signs in to its host; adds AWS and DigitalOcean through stand-in CLIs (`clouds/`); adds a feed (`host-feed.mjs`) whose host connects with the host key it lists, whose wrongly listed host is refused and whose changed snippet asks before it runs; signs in with a key that only has a certificate from the run's user CA (which sshd trusts), from the vault and from an agent; reaches a host by a command its jump host runs (with a login command instead of the shell), and another by a proxy command on this computer (both the relay in `crates/mg-ssh/tests/relay.py`); selects two hosts, moves them into a group and deletes them; signs in to a host with one key from an `ssh-agent` (its own socket) and forwards it (`ssh-add -l` on the host lists it); splits a tab into three panes (beside and below, one through a jump host), broadcasts a command to all of them and then types into one, drags a border and closes them; opens a `morphgate://connect` link from a second launch (which hands it over and exits) and connects from the filled-in quick connect, and refuses an unknown link; refuses a tampered update twice without touching the AppImage; installs the signed update, restarts, unlocks the same vault as the new version, and finds nothing newer | | `package.mjs` (deb, rpm) | The binary knows how it was installed, finds the update, and warns that installing asks for an administrator password (installing needs root, so it stops there) | | `teams.mjs` | Two apps side by side (Alice and Bob) with a local reference sync server: both turn on sync; Alice creates an organization and a vault and invites Bob; Bob joins only after the owner's fingerprint matches, and Alice confirms him only after his matches; a host saved in the shared vault reaches Bob, his edit reaches Alice; Bob publishes his SSH key; Alice revokes him (the vault leaves his device) and rotates the vault key; members may create vaults; the activity log tells the story. Then Alice offboards Bob against the local sshd: removal and key rotation, his key found and removed from `authorized_keys`, and the host's shared key replaced (she still logs in) | -| `layout.mjs` | Turns sync on from the offer a new vault gets, fills every page (hosts in a group, a key, a snippet, a tunnel, a team with an invite to a long address), and at 860, 1024, 1280 and 1600 px wide checks that nothing on a page overlaps or runs out of its column. Closing an editor returns to the page it was opened from, and closing a page to the session under it; with no session open, a page takes the tab bar's place | +| `layout.mjs` | Turns sync on from the offer a vault without sync gets a week on, fills every page (hosts in a group, a key, a snippet, a tunnel, a team with an invite to a long address), and at 860, 1024, 1280 and 1600 px wide checks that nothing on a page overlaps or runs out of its column. Closing an editor returns to the page it was opened from, and closing a page to the session under it; with no session open, a page takes the tab bar's place | +| `monkey.mjs` | Only with `E2E_ONLY=monkey`: each package (AppImage, deb, rpm) takes `MONKEY_STEPS` seeded random clicks, typing, keys, selects and resizes over a vault with hosts where nothing listens; any page error, blank page, unanswered call or Rust panic fails it, with the seed and the last steps in `monkey-/failure.txt` (`MONKEY_SEED` repeats it) | +| `demo.mjs` | Only with `E2E_ONLY=demo`: the README's demo. The AppImage's Hosts page, a click on a host and its recorded session (in a demo shell), drawn by `tools/readme-demo/demo-svg.mjs` as `demo/morphgate.svg` (copy it to `assets/demo/morphgate.svg`) | | `mobile.mjs` | The phone layout in a 390 × 844 window: imports a key and saves a host on the phone screens, connects from the host's sheet, interrupts a command with Ctrl from the extra-key row and repeats one with ↑, keeps the session open behind the host list, browses into a folder over SFTP and downloads a file to Downloads, then locks and unlocks on the phone unlock screen | ```sh @@ -18,16 +20,49 @@ E2E_SKIP_BUILD=1 E2E_ONLY=teams apps/desktop/e2e/run.sh # just some scenarios: Screenshots and logs go to `target/e2e/run-