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
8 changes: 5 additions & 3 deletions .github/scripts/need-packages.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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[@]}"
91 changes: 90 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ prompt. Write them for users.
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
Expand Down
37 changes: 35 additions & 2 deletions apps/desktop/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ 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; 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 |
| `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-<package>/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 |
Expand All @@ -20,6 +20,39 @@ E2E_SKIP_BUILD=1 E2E_ONLY=teams apps/desktop/e2e/run.sh # just some scenarios:

Screenshots and logs go to `target/e2e/run-<time>/`.

## Android monkey

`android-monkey.mjs` isn't one of `run.sh`'s scenarios: it runs Android's own monkey
(`adb shell monkey`) on the Android app, on an emulator or a phone adb reaches. CI's "Android
monkey" job runs a basic one in an API 35 emulator on every pull request (3 rounds of 500 events,
ANRs noted, a 720 × 1600 screen); it isn't a required check.

It takes a debug build (for the emulator's processor), whose WebView answers DevTools: through it
the app's first screen creates the vault (then a key, groups, hosts where nothing listens and a
snippet go in), its unlock screen opens it, and what the page throws is heard (with when: the
app's start, the setup or the walk). It never reloads the page, which would drop the app's calls
still under way. It waits for the device to finish what it does after booting (up to 5 minutes;
a fresh emulator compiles apps for a while). Then `MONKEY_ROUNDS` (5) rounds of `MONKEY_EVENTS`
(1000) touches, swipes, screen turns and navigation keys, each round from a fresh start with the
app's task pinned (so Back and swipes stay in it; starts of other apps are refused). It fails on a
crash or an ANR, a Rust panic or a Java exception in the log, an error the page throws, the process
ending, the page going blank or the app no longer drawing a frame at the end of a round, writing
`failure.txt` with the seed; `MONKEY_SEED` repeats the events (not always what they hit: timing
differs). On an emulator, where the ANR reports can be read (through `su`), the main thread's stack
goes to the round's `anr.txt`. `MONKEY_ANR=note` notes ANRs instead of failing on them, for an
emulator drawing in software, whose frames can take longer than the 5 seconds Android gives a
touch; the app still has to draw again by the end of the round. It starts by clearing the app's
data, so don't point it at a phone whose MorphGate you use.

```sh
cd apps/desktop && npx tauri android build --debug --apk --target x86_64 && cd ../..
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
```

Each round's logcat, the monkey's output and a screenshot go to `round-<n>/`; CI uploads them as
the `android-monkey` artifact for 7 days.

## How it stays out of your way

- **Display:** each scenario runs in a headless `mutter` inside `dbus-run-session`. Nothing shows on your
Expand Down
Loading