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)
+
+
+
+
## 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-/`.
+## 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-/`; 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
screen, and nothing reaches your session bus or keyring.
- **Data:** `HOME` and the XDG directories point at the run directory, so your vault and settings are
never read or written.
-- **Network:** everything is on loopback (sshd on 127.0.0.1 and 127.0.0.2), on ports free when the
+- **Network:** everything is on loopback (sshd on 127.0.0.1 to 127.0.0.4), on ports free when the
run starts, so runs can share a machine: sshd (`E2E_SSH_PORT`), the update feed
(`E2E_FEED_PORT`), the sync server (`E2E_SYNC_PORT`), the test identity provider
- (`E2E_IDP_PORT`), and two per tauri-driver (`E2E_DRIVER_PORT`: from that port up). Set them to
+ (`E2E_IDP_PORT`), the stand-in feed of hosts (`E2E_HOST_FEED_PORT`), and two per tauri-driver (`E2E_DRIVER_PORT`: from that port up). Set them to
pin a port.
- **Files on the "server":** sshd's SFTP sessions start in the run's `sshd/home`, whose
`.ssh/authorized_keys` is the file sshd reads, so the offboarding steps edit that copy and never
diff --git a/apps/desktop/e2e/android-monkey.mjs b/apps/desktop/e2e/android-monkey.mjs
new file mode 100644
index 0000000..45f3d0b
--- /dev/null
+++ b/apps/desktop/e2e/android-monkey.mjs
@@ -0,0 +1,311 @@
+// Android's monkey on the Android app: random touches, swipes, rotations and keys, from the
+// platform's own tool (`adb shell monkey`), on an emulator or a phone that adb reaches. The app is
+// a debug build, whose WebView answers DevTools: through it the first screen creates a vault (then
+// a key, groups, hosts where nothing listens and a snippet go in), the unlock screen opens it, and
+// every error the page throws is heard. The walk runs
+// in rounds, each from a fresh start with the app unlocked and its task pinned (so Back and swipes
+// stay in it). A crash, a hang (ANR), a Rust panic, an uncaught error, a blank page or an app that
+// no longer draws fails it.
+//
+// node android-monkey.mjs
+//
+// MONKEY_ROUNDS (5) of MONKEY_EVENTS (1000) each; MONKEY_SEED (random, printed) seeds round n
+// with seed + n. A seed repeats the walk's events, though not always what they hit: the app's
+// timing differs from run to run. MONKEY_ANR=note (for an emulator drawing in software, whose
+// frames can take longer than the 5 seconds Android gives a touch) notes an ANR instead of
+// failing on it; the app still has to draw again by the end of the round.
+
+import { execFileSync, spawn } from "node:child_process";
+import { createWriteStream, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+
+const [apk, out] = process.argv.slice(2);
+const PKG = process.env.MONKEY_PACKAGE || "com.morphgate.app";
+const ROUNDS = Number(process.env.MONKEY_ROUNDS || 5);
+const EVENTS = Number(process.env.MONKEY_EVENTS || 1000);
+const SEED = Number(process.env.MONKEY_SEED || Math.floor(Math.random() * 2 ** 31));
+const PASSWORD = "correct horse battery staple";
+const ADB = process.env.ADB || "adb";
+const ANR = process.env.MONKEY_ANR === "note" ? "note" : "fail";
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+mkdirSync(out, { recursive: true });
+
+const adb = (...args) => execFileSync(ADB, args, { encoding: "utf8", maxBuffer: 64 << 20 }).trim();
+/** As `adb`, "" when it fails (`pidof` of a process that isn't running, say). */
+const tryAdb = (...args) => {
+ try {
+ return adb(...args);
+ } catch {
+ return "";
+ }
+};
+
+async function until(what, fn, ms = 90000) {
+ const end = Date.now() + ms;
+ while (Date.now() < end) {
+ const v = await fn();
+ if (v) return v;
+ await sleep(500);
+ }
+ throw new Error(`timed out waiting for ${what}`);
+}
+
+/** The app's page over the DevTools protocol: `evaluate` runs an expression (awaiting a promise),
+ * `thrown` collects what the page throws (during `phase`: the app's start, the setup, the walk),
+ * `gone` says why the connection ended. */
+async function devtools(pid) {
+ const socket = `webview_devtools_remote_${pid}`;
+ await until("the WebView's DevTools socket", () => new RegExp(`@${socket}$`, "m").test(tryAdb("shell", "cat", "/proc/net/unix")));
+ const port = adb("forward", "tcp:0", `localabstract:${socket}`);
+ const page = await until("the app's page", async () => {
+ try {
+ const list = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
+ return list.find((p) => p.type === "page" && p.webSocketDebuggerUrl);
+ } catch {
+ return null;
+ }
+ });
+ const ws = new WebSocket(page.webSocketDebuggerUrl);
+ await new Promise((ok, fail) => {
+ ws.onopen = ok;
+ ws.onerror = () => fail(new Error("DevTools didn't connect"));
+ });
+ const waiting = new Map();
+ const d = { thrown: [], gone: null, phase: "start" };
+ let next = 1;
+ ws.onmessage = (m) => {
+ const msg = JSON.parse(m.data);
+ if (msg.id && waiting.has(msg.id)) {
+ const w = waiting.get(msg.id);
+ waiting.delete(msg.id);
+ if (msg.error) w.fail(new Error(msg.error.message));
+ else w.ok(msg.result);
+ } else if (msg.method === "Runtime.exceptionThrown") {
+ const e = msg.params.exceptionDetails;
+ d.thrown.push(`${d.phase}: ${(e.exception?.description ?? e.text).split("\n")[0]}`);
+ } else if (msg.method === "Inspector.targetCrashed") {
+ d.gone = "the page crashed";
+ } else if (msg.method === "Inspector.detached") {
+ d.gone = `the page went away (${msg.params.reason})`;
+ }
+ };
+ ws.onclose = () => {
+ d.gone ??= "DevTools disconnected";
+ for (const w of waiting.values()) w.fail(new Error(d.gone));
+ waiting.clear();
+ };
+ const send = (method, params = {}) =>
+ new Promise((ok, fail) => {
+ if (d.gone) return fail(new Error(d.gone));
+ const id = next++;
+ waiting.set(id, { ok, fail });
+ ws.send(JSON.stringify({ id, method, params }));
+ });
+ d.evaluate = async (expression) => {
+ const r = await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
+ if (r.exceptionDetails) throw new Error((r.exceptionDetails.exception?.description ?? r.exceptionDetails.text).split("\n")[0]);
+ return r.result.value;
+ };
+ d.close = () => {
+ ws.close();
+ tryAdb("forward", "--remove", `tcp:${port}`);
+ };
+ await send("Inspector.enable");
+ await send("Runtime.enable");
+ return d;
+}
+
+// In the page: typing into the field a label names, clicking a button by its text (as a person
+// would, through the app's own screens: the page is never reloaded, which would drop the app's
+// calls still under way).
+const PAGE = `
+ const type = (label, value) => {
+ const e = [...document.querySelectorAll("label")].find((l) => l.innerText.includes(label))?.querySelector("input");
+ if (!e) return false;
+ Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value").set.call(e, value);
+ e.dispatchEvent(new Event("input", { bubbles: true }));
+ return true;
+ };
+ const click = (text, css = "button") => {
+ const b = [...document.querySelectorAll(css)].find((b) => b.innerText.trim() === text && !b.disabled);
+ b?.click();
+ return !!b;
+ };`;
+const inPage = (d, body) => d.evaluate(`(() => { ${PAGE}\n${body} })()`).catch(() => null);
+const WORKSPACE = `return document.querySelector(".m-home, button.brand") ? true : null`;
+
+/** The vault unlocked (created, with something on every page, the first time) through the app's
+ * first screen or its unlock screen, the workspace showing. */
+async function unlock(d) {
+ const status = await until("the app's script", () =>
+ d.evaluate(`window.__TAURI_INTERNALS__ ? window.__TAURI_INTERNALS__.invoke("vault_status") : null`).catch(() => null),
+ );
+ if (status === "uninitialized") {
+ await until("the first screen", () => inPage(d, `return type("Master password", ${JSON.stringify(PASSWORD)}) && type("Repeat password", ${JSON.stringify(PASSWORD)})`));
+ await until("Create vault", () => inPage(d, `return click("Create vault")`));
+ await until("the recovery key", () => inPage(d, `const c = document.querySelector("label.check input"); if (!c) return null; if (!c.checked) c.click(); return true`));
+ await until("Open my vault", () => inPage(d, `return click("Open my vault")`));
+ await until("the workspace", () => inPage(d, WORKSPACE));
+ await d.evaluate(`(async () => {
+ const invoke = window.__TAURI_INTERNALS__.invoke;
+ const key = await invoke("key_generate", { label: "monkey", vaultId: null });
+ const prod = (await invoke("group_save", { input: { name: "Production" } })).id;
+ await invoke("group_save", { input: { name: "Staging" } });
+ const auth = { kind: "key", keyId: key.id };
+ for (const [label, hostname, groupId] of [["web-1", "127.0.0.9", prod], ["web-2", "127.0.0.10", prod], ["db-1", "127.0.0.11", null]]) {
+ await invoke("host_save", { input: { label, hostname, port: 1, user: "deploy", auth, groupId, tags: ["monkey"] } });
+ }
+ await invoke("snippet_save", { input: { name: "Disk", body: "df -h", tags: ["ops"] } });
+ await invoke("plugin:event|emit", { event: "vault-changed", payload: null });
+ return true;
+ })()`);
+ } else if (status === "locked") {
+ await until("the unlock screen", () => inPage(d, `click("Use master password"); return type("Master password", ${JSON.stringify(PASSWORD)})`));
+ await until("Unlock", () => inPage(d, `return click("Unlock", "button[type=submit]")`));
+ }
+ await until("the workspace", () => inPage(d, WORKSPACE));
+}
+
+/** The app's task, pinned: the monkey's Back and swipes can't take it out of the app. */
+function pin() {
+ const stacks = tryAdb("shell", "am", "stack", "list");
+ const recents = stacks ? "" : tryAdb("shell", "dumpsys", "activity", "recents");
+ const id =
+ stacks.split("\n").find((l) => l.includes(`${PKG}/`))?.match(/taskId=(\d+)/)?.[1] ??
+ recents.match(new RegExp(`#(\\d+) type=standard A=\\d+:${PKG.replace(/\./g, "\\.")}\\b`))?.[1];
+ if (id) tryAdb("shell", "am", "task", "lock", id);
+ return !!id;
+}
+
+/** Once the device is done with what it does after booting (a fresh emulator compiles apps for
+ * minutes, and a walk then hangs on a busy processor): the load under its processor count, or 5
+ * minutes on. */
+async function settle() {
+ const cores = Number(tryAdb("shell", "nproc")) || 4;
+ const load = () => Number(tryAdb("shell", "cat", "/proc/loadavg").split(" ")[0]) || 0;
+ const start = Date.now();
+ while (load() >= cores && Date.now() - start < 300000) await sleep(5000);
+ return `waited ${Math.round((Date.now() - start) / 1000)} s for the device: load ${load()} on ${cores} processors`;
+}
+
+/** The ANR reports in /data/anr, readable through su on an emulator (none on a phone). */
+const anrReports = () =>
+ tryAdb("shell", "su", "0", "ls", "/data/anr")
+ .split("\n")
+ .filter((f) => f.startsWith("anr_"));
+
+/** The app's main thread in an ANR report: where it was stuck. */
+function mainThread(file) {
+ const report = tryAdb("shell", "su", "0", "cat", `/data/anr/${file}`);
+ if (!report.includes(`Cmd line: ${PKG}`)) return null;
+ return report.match(/^"main" [\s\S]*?(?=\n\s*\n)/m)?.[0] ?? null;
+}
+
+/** What went wrong in a round's logcat (lines "date time pid tid level tag: message"): a Rust
+ * panic, a Java exception or a fatal signal in the app's process, or a native crash's report. */
+function logProblems(log, pid) {
+ return log
+ .split("\n")
+ .filter((l) => (l.split(/\s+/)[2] === pid && / panicked at |FATAL EXCEPTION|Fatal signal/.test(l)) || l.includes(`>>> ${PKG} <<<`))
+ .slice(0, 10);
+}
+
+async function fail(dir, round, why, extra = []) {
+ const report = [`Android monkey: ${why}`, `seed ${SEED}, round ${round} of ${ROUNDS} (MONKEY_SEED=${SEED} repeats it)`, ...extra].join("\n");
+ writeFileSync(`${out}/failure.txt`, `${report}\n`);
+ writeFileSync(`${dir}/end.png`, execFileSync(ADB, ["exec-out", "screencap", "-p"], { maxBuffer: 64 << 20 }));
+ throw new Error(report);
+}
+
+// From no data: round 1 creates the vault (so not a phone whose MorphGate you use).
+tryAdb("shell", "am", "task", "lock", "stop");
+adb("install", "-r", apk);
+adb("shell", "pm", "clear", PKG);
+console.log(` Android monkey: seed ${SEED}, ${ROUNDS} rounds of ${EVENTS} events; ${await settle()}`);
+const totals = { rejected: 0, pins: 0, anrs: 0 };
+
+for (let round = 1; round <= ROUNDS; round++) {
+ const dir = `${out}/round-${round}`;
+ mkdirSync(dir, { recursive: true });
+ tryAdb("shell", "am", "task", "lock", "stop");
+ tryAdb("shell", "am", "force-stop", PKG);
+ tryAdb("logcat", "-c");
+ const logcat = spawn(ADB, ["logcat", "-v", "threadtime"], { stdio: ["ignore", "pipe", "ignore"] });
+ logcat.stdout.pipe(createWriteStream(`${dir}/logcat.txt`));
+ let d = null;
+ try {
+ adb("shell", "monkey", "-p", PKG, "-c", "android.intent.category.LAUNCHER", "1");
+ const pid = await until("the app's process", () => tryAdb("shell", "pidof", PKG));
+ d = await devtools(pid);
+ d.phase = "setup";
+ await unlock(d);
+ if (pin()) totals.pins++;
+ await sleep(1000);
+ d.phase = "walk";
+ const reports = new Set(anrReports());
+
+ // The walk: mostly touches and swipes, some turns of the screen. No system keys or app
+ // switches (they'd only leave the app), no random key codes (some sleep the device); typing
+ // happens on the on-screen keyboard.
+ const walk = spawn(
+ ADB,
+ [
+ "shell", "monkey", "-p", PKG, "-s", String(SEED + round), "--throttle", "100",
+ "--pct-syskeys", "0", "--pct-appswitch", "0", "--pct-anyevent", "0", "--pct-trackball", "0",
+ "--pct-touch", "45", "--pct-motion", "20", "--pct-rotation", "3",
+ "--ignore-security-exceptions", ...(ANR === "note" ? ["--ignore-timeouts"] : []), "-v", "-v", String(EVENTS),
+ ],
+ { stdio: ["ignore", "pipe", "pipe"] },
+ );
+ let text = "";
+ walk.stdout.on("data", (b) => (text += b));
+ walk.stderr.on("data", (b) => (text += b));
+ await new Promise((r) => walk.on("close", r));
+ writeFileSync(`${dir}/monkey.txt`, text);
+ await sleep(2000);
+
+ const after = tryAdb("shell", "pidof", PKG);
+ const crash = text.match(/^\/\/ CRASH: .*$/m);
+ const anrs = (text.match(/^\/\/ NOT RESPONDING: /gm) ?? []).length;
+ // Where the main thread was each time, kept with the round.
+ const stuck = anrReports()
+ .filter((f) => !reports.has(f))
+ .map(mainThread)
+ .filter(Boolean);
+ if (stuck.length) writeFileSync(`${dir}/anr.txt`, `${stuck.join("\n\n")}\n`);
+ totals.anrs += anrs;
+ // A frame: the WebView draws when the app's main thread does, so a hung app shows here even
+ // with ANRs noted. In front first (the walk may have left it behind).
+ tryAdb("shell", "monkey", "-p", PKG, "-c", "android.intent.category.LAUNCHER", "1");
+ const drawn = d.gone
+ ? null
+ : await d
+ .evaluate(`new Promise((ok) => { requestAnimationFrame(() => ok(document.getElementById("root")?.childElementCount ?? 0)); setTimeout(() => ok(null), 30000); })`)
+ .catch(() => null);
+ const focus = tryAdb("shell", "dumpsys", "window").match(/mCurrentFocus=Window\{\S+ \S+ ([^}]+)\}/)?.[1] ?? "?";
+ const injected = text.match(/Events injected: (\d+)/)?.[1] ?? "0";
+ totals.rejected += (text.match(/\/\/ Rejecting start of Intent/g) ?? []).length;
+ logcat.kill();
+ await sleep(500);
+ const problems = logProblems(readFileSync(`${dir}/logcat.txt`, "utf8"), pid);
+
+ if (crash) await fail(dir, round, "the app crashed", text.split("\n").filter((l) => l.startsWith("// ")).slice(0, 20));
+ if (anrs && ANR === "fail") await fail(dir, round, "the app stopped responding", stuck[0]?.split("\n").slice(0, 25) ?? ["(its ANR report needs su to read: an emulator's)"]);
+ if (problems.length) await fail(dir, round, "the app's log has a crash or a panic", problems);
+ if (d.thrown.length) await fail(dir, round, `the page threw: ${d.thrown.join(" | ")}`);
+ if (after !== pid) await fail(dir, round, `the app's process ${after ? `restarted (${pid} → ${after})` : "ended"}`);
+ if (d.gone) await fail(dir, round, d.gone);
+ if (drawn === null) await fail(dir, round, "the app stopped drawing (its main thread is stuck)", stuck[0]?.split("\n").slice(0, 25) ?? []);
+ if (!drawn) await fail(dir, round, "the page went blank (a render crashed)");
+ writeFileSync(`${dir}/end.png`, execFileSync(ADB, ["exec-out", "screencap", "-p"], { maxBuffer: 64 << 20 }));
+ const slow = anrs ? `; ${anrs} ANR${anrs > 1 ? "s" : ""} noted${stuck.length ? " (anr.txt)" : ""}, then it drew again` : "";
+ console.log(` round ${round}: ${injected} events, nothing crashed or threw${slow}; in front: ${focus}`);
+ } finally {
+ logcat.kill();
+ d?.close();
+ }
+}
+tryAdb("shell", "am", "task", "lock", "stop");
+console.log(
+ ` ✓ Android monkey: ${ROUNDS} rounds of ${EVENTS} events, nothing crashed, hung, panicked or threw (pinned ${totals.pins} of ${ROUNDS}; ` +
+ `${totals.rejected} starts of other apps refused${totals.anrs ? `; ${totals.anrs} ANRs noted` : ""}), seed ${SEED}`,
+);
diff --git a/apps/desktop/e2e/appimage.mjs b/apps/desktop/e2e/appimage.mjs
index 8355bd7..0a005ca 100644
--- a/apps/desktop/e2e/appimage.mjs
+++ b/apps/desktop/e2e/appimage.mjs
@@ -9,7 +9,7 @@ import { appendFileSync, chmodSync, copyFileSync, existsSync, mkdirSync, mkdtemp
import { basename, dirname } from "node:path";
import { createHash, createHmac, generateKeyPairSync } from "node:crypto";
import { fileURLToPath } from "node:url";
-import { createVault, openAbout, session } from "./webdriver.mjs";
+import { createVault, openAbout, reload, session } from "./webdriver.mjs";
const [app, shots, feed, key, port, user, version, next] = process.argv.slice(2);
@@ -60,6 +60,18 @@ async function quickConnect(s) {
await s.exec(`document.querySelector("button[type=submit].primary").click(); return true`);
}
+/** Clicks "Check now" in About and waits for that check to finish: the update shown before it
+ * may be from the check at launch, and Install takes the one the last finished check found. */
+async function checkNow(s) {
+ await s.exec(`const b = [...document.querySelectorAll("button")].find((b) => b.innerText.includes("Check now") && !b.disabled);
+ if (!b) throw new Error("no enabled button Check now");
+ window.__checked = false;
+ new MutationObserver((_, o) => { if (b.innerText.includes("Check now")) { window.__checked = true; o.disconnect(); } })
+ .observe(b, { subtree: true, childList: true, characterData: true });
+ b.click(); return true`);
+ await s.wait(() => s.exec(`return window.__checked || null`), "the update check", 60000);
+}
+
let s = await session(app);
try {
// A guessable master password is refused, and the form says why.
@@ -75,6 +87,21 @@ try {
await createVault(s, PASSWORD, shots);
ok("refused a guessable master password, saying why; created a vault with a strong one");
+ // Sync isn't offered with the new vault (the first screen had New account and Sign in), but a
+ // week on, once.
+ const offered = () => s.exec(`return !!document.querySelector("dialog.sync-offer")`);
+ await new Promise((r) => setTimeout(r, 1500));
+ assert.equal(await offered(), false, "sync offered with the new vault");
+ await reload(s, { weekLater: true });
+ await s.wait(() => s.exec(`return document.querySelector("dialog.sync-offer[open]") ? true : null`), "the offer to turn sync on");
+ await s.screenshot(`${shots}/sync-offer.png`);
+ await s.exec(`[...document.querySelectorAll("dialog.sync-offer button")].find((b) => b.innerText === "Not now").click(); return true`);
+ await s.wait(() => s.exec(`return document.querySelector("dialog.sync-offer") ? null : true`), "the offer to close");
+ await reload(s, { weekLater: true });
+ await new Promise((r) => setTimeout(r, 1500));
+ assert.equal(await offered(), false, "sync offered again after Not now");
+ ok("sync is offered a week after the vault was created, not with it, and once");
+
// Quick connect with a key file; the new host key is trusted once.
await quickConnect(s);
await s.waitText("Trust and connect");
@@ -126,7 +153,7 @@ try {
);
await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Hosts")).click(); return true`);
await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === "Hosts" || null`), "the Hosts page");
- await s.clickButton("Import…");
+ await s.addFrom("Import…");
await s.wait(() => s.exec(`return document.querySelectorAll(".import-list li").length === 2 || null`), "the two hosts in ~/.ssh/config");
const listed = await s.exec(`return document.querySelector(".import-hosts").innerText`);
assert.match(listed, /Match blocks are skipped/);
@@ -179,7 +206,7 @@ try {
writeFileSync(`${home}/.putty/sessions/router`, "HostName=10.0.0.1\nProtocol=telnet\n");
await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Hosts")).click(); return true`);
await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === "Hosts" || null`), "the Hosts page");
- await s.clickButton("Import…");
+ await s.addFrom("Import…");
await s.clickButton("PuTTY");
await s.wait(() => s.exec(`return document.querySelector(".import-list")?.innerText.includes("E2E PuTTY") || null`), "the PuTTY session");
let page = await s.exec(`return document.querySelector(".import-hosts").innerText`);
@@ -363,6 +390,7 @@ try {
// host key prompt, and the route bar says who vouched for it.
await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Keys")).click(); return true`);
await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === "Keys" || null`), "the Keys page");
+ await s.pageTab("Host CAs");
await s.clickButton("New host CA");
await s.waitText("New host CA", ".panel");
await s.typeLabelled("Name", "e2e host CA");
@@ -379,7 +407,7 @@ try {
throw e;
});
assert.equal(saved, "ok", `saving the host CA: ${saved}`);
- await s.wait(() => s.hasText(".panel strong", "e2e host CA"), "the host CA listed");
+ await s.wait(() => s.hasText(".panel .lname", "e2e host CA"), "the host CA listed");
await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Hosts")).click(); return true`);
await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === "Hosts" || null`), "the Hosts page");
await s.clickButton("New host");
@@ -439,7 +467,9 @@ try {
// the key goes on the account; a refresh changes nothing; no Files for its hosts (no SFTP).
await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Hosts")).click(); return true`);
await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === "Hosts" || null`), "the Hosts page");
- await s.clickButton("Clouds…");
+ await s.addFrom("Clouds…");
+ // The page shows what to add once it has listed the sources.
+ await s.waitText("Connect Vallic Cloud", ".cloud-page", 30000);
await s.clickButton("Connect Vallic Cloud");
await s.waitText("WXYZ-1234", ".cloud-sign-in", 30000);
await s.screenshot(`${shots}/vallic-sign-in.png`);
@@ -466,10 +496,10 @@ try {
await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Hosts")).click(); return true`);
await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === "Hosts" || null`), "the Hosts page again");
const vallicRow = await s.exec(
- `const li = [...document.querySelectorAll(".panel.page li")].find((l) => l.innerText.includes("E2E Shop · Production"));
- return li ? [...li.querySelectorAll("button")].map((b) => b.innerText).join("|") : null`,
+ `const row = [...document.querySelectorAll(".panel.page [role=row]")].find((r) => r.innerText.includes("E2E Shop · Production"));
+ return row ? [...row.querySelectorAll("button")].map((b) => b.getAttribute("aria-label") || b.innerText).join("|") : null`,
);
- assert.ok(vallicRow && !vallicRow.split("|").includes("Files"), `no Files for a Vallic host: ${vallicRow}`);
+ assert.ok(vallicRow && !vallicRow.split("|").includes("Browse files (SFTP)"), `no Files for a Vallic host: ${vallicRow}`);
// Its SSH forwards no ports: the tunnel editor doesn't offer it.
await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Tunnels")).click(); return true`);
await s.clickButton("New tunnel");
@@ -499,7 +529,7 @@ try {
// the stopped one without an address waits.
await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Hosts")).click(); return true`);
await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === "Hosts" || null`), "the Hosts page");
- await s.clickButton("Clouds…");
+ await s.addFrom("Clouds…");
// The page shows what to add once it has listed the sources.
await s.waitText("Add AWS", ".cloud-page", 30000);
await s.clickButton("Add AWS");
@@ -530,7 +560,8 @@ try {
// connection goes through `aws ssm start-session` (the stand-in relays it to this sshd).
await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Hosts")).click(); return true`);
await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === "Hosts" || null`), "the Hosts page");
- await s.clickButton("Clouds…");
+ await s.addFrom("Clouds…");
+ await s.waitText("123456789012", ".cloud-source", 30000);
await s.exec(
`const card = [...document.querySelectorAll(".cloud-source")].find((x) => x.innerText.includes("123456789012"));
[...card.querySelectorAll("button")].find((b) => b.innerText === "Settings…").click(); return true`,
@@ -570,7 +601,7 @@ try {
// for the source; its droplet (this sshd) connects.
await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Hosts")).click(); return true`);
await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === "Hosts" || null`), "the Hosts page");
- await s.clickButton("Clouds…");
+ await s.addFrom("Clouds…");
await s.waitText("Add DigitalOcean", ".cloud-page", 30000);
await s.clickButton("Add DigitalOcean");
await s.waitText("is signed in as e2e@example.com (E2E)", ".cloud-adding", 30000);
@@ -606,6 +637,127 @@ try {
await s.click(".tab.active .close");
ok("added DigitalOcean through its CLI: its droplet became a host and connected");
+ // A feed (host-feed.mjs): Add from… → Feed URL…, read with its token; its host connects with
+ // the host key the feed lists (nothing asks), another listed with the wrong key is refused, an
+ // invalid entry is skipped and said; its snippet is read-only, and once the feed changes it,
+ // running it asks first.
+ await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Hosts")).click(); return true`);
+ await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === "Hosts" || null`), "the Hosts page");
+ await s.addFrom("Feed URL…");
+ await s.waitText("Add a feed", ".cloud-adding", 30000);
+ await s.typeLabelled("Address", process.env.E2E_HOST_FEED);
+ await s.typeLabelled("Token", "wrong-token");
+ await s.clickButton("Add and read the feed");
+ await s.waitText("refused the token", ".cloud-page > .error", 30000);
+ await s.typeLabelled("Token", process.env.E2E_HOST_FEED_TOKEN);
+ await s.clickButton("Add and read the feed");
+ // Titles are uppercased by CSS (innerText follows it).
+ const feedCard = `[...document.querySelectorAll(".cloud-source")].find((x) => x.innerText.toLowerCase().includes("e2e operations"))`;
+ const feedAdded = await s
+ .wait(
+ () => s.exec(`return ${feedCard}?.innerText.includes("2 hosts added") ? "ok" : document.querySelector(".cloud-page > .error, .cloud-adding .error")?.innerText || null`),
+ "the feed source",
+ 30000,
+ )
+ .catch(async (e) => {
+ await s.screenshot(`${shots}/feed-not-added.png`);
+ throw e;
+ });
+ assert.equal(feedAdded, "ok", `adding the feed: ${feedAdded}`);
+ page = await s.exec(`return ${feedCard}.innerText`);
+ assert.match(page, /1 snippet added/);
+ assert.match(page, /Skipped: broken: the host name/);
+ assert.match(page, /token …cdef/);
+ assert.ok(!page.includes(process.env.E2E_HOST_FEED_TOKEN), "the token isn't shown");
+ await s.screenshot(`${shots}/feed-source.png`);
+ await s.clickButton("Close");
+ await s.exec(
+ `[...document.querySelectorAll(".sidebar .list li")].find((l) => l.querySelector(".row-title")?.innerText === "e2e-feed-web").querySelector(".row-main").click(); return true`,
+ );
+ await s.wait(
+ () =>
+ s.exec(
+ `if (document.querySelector("dialog.hostkey[open]")) throw new Error("asked to trust the feed's host");
+ const r = document.querySelector(".terminal:not(.xterm):not([hidden]) .xterm-rows"); return r && /[$#]\\s*$/m.test(r.innerText) ? true : null`,
+ ),
+ "a shell on the feed's host, nothing asked",
+ 60000,
+ );
+ await s.exec(
+ `[...document.querySelectorAll(".sidebar .list li")].find((l) => l.querySelector(".row-title")?.innerText === "e2e-feed-wrong").querySelector(".row-main").click(); return true`,
+ );
+ // The terminal wraps mid-word: compared without whitespace.
+ const refusedText = await s
+ .wait(
+ () =>
+ s.exec(
+ `const r = (document.querySelector(".terminal:not(.xterm):not([hidden]) .xterm-rows")?.innerText ?? "").replace(/\\s+/g, "");
+ return r.includes("isn'toneE2Eoperationslistsforit:refused") ? r : null`,
+ ),
+ "the wrong key refused",
+ 60000,
+ )
+ .catch(async (e) => {
+ await s.screenshot(`${shots}/feed-wrong-key.png`);
+ throw e;
+ });
+ assert.ok(!refusedText.includes("$"), "no shell on the wrongly listed host");
+ await s.click(".tab.active .close");
+ // Back to the session on e2e-feed-web: where snippets go.
+ await s.exec(`[...document.querySelectorAll(".tab")].find((t) => t.querySelector(".label")?.innerText === "e2e-feed-web").click(); return true`);
+ ok("added a feed (a wrong token said first): its host connected with the key the feed lists, one listed with another key was refused, an invalid entry skipped");
+
+ // Its snippet, from the Snippets page into the session on e2e-feed-web.
+ await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Snippets")).click(); return true`);
+ await s.wait(() => s.hasText(".panel .lname", "E2E hello"), "the feed's snippet");
+ const feedRow = `[...document.querySelectorAll(".panel.page [role=row]")].find((r) => r.innerText.includes("E2E hello"))`;
+ assert.match(await s.exec(`return ${feedRow}.innerText`), /E2E operations/i);
+ const runFeedSnippet = () =>
+ s.exec(`[...${feedRow}.querySelectorAll("button")].find((b) => b.innerText === "Run").click(); return true`);
+ await runFeedSnippet();
+ await s.wait(
+ () => s.exec(`const r = document.querySelector(".terminal:not(.xterm):not([hidden]) .xterm-rows"); return r && /^e2e-feed-one\s*$/m.test(r.innerText) ? true : null`),
+ "the snippet's output",
+ 30000,
+ );
+ // Read-only: no Save, a copy instead.
+ await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Snippets")).click(); return true`);
+ await s.wait(() => s.hasText(".panel .lname", "E2E hello"), "the Snippets page again");
+ await s.exec(`${feedRow}.querySelector(".lnamebtn").click(); return true`);
+ await s.waitText("it changes there, and is read-only here", ".panel", 30000);
+ assert.ok(!(await s.exec(`return [...document.querySelectorAll(".panel button")].some((b) => b.innerText.startsWith("Save"))`)), "no Save");
+ await s.clickButton("Close");
+
+ // The feed changes the snippet: a refresh brings it, and running it asks first.
+ await fetch(`${new URL(process.env.E2E_HOST_FEED).origin}/e2e/next`, { method: "POST" });
+ await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Hosts")).click(); return true`);
+ await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === "Hosts" || null`), "the Hosts page");
+ await s.addFrom("Clouds…");
+ await s.waitText("E2E operations", ".cloud-source", 30000);
+ await s.exec(`[...${feedCard}.querySelectorAll("button")].find((b) => b.innerText === "Refresh").click(); return true`);
+ await s.wait(() => s.exec(`return ${feedCard}?.innerText.match(/1 gone from the feed, 1 snippet changed/) ? true : null`), "the feed's next version", 30000);
+ await s.clickButton("Close");
+ await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Snippets")).click(); return true`);
+ await s.wait(() => s.hasText(".panel .lname", "E2E hello"), "the Snippets page");
+ await runFeedSnippet();
+ await s.waitText("E2E hello changed", "dialog.snippet-check", 30000);
+ assert.match(await s.exec(`return document.querySelector("dialog.snippet-check").innerText`), /Changed by E2E operations[\s\S]*echo e2e-feed-two/);
+ await s.screenshot(`${shots}/feed-snippet-changed.png`);
+ await s.clickButton("Run it");
+ await s.wait(
+ () => s.exec(`const r = document.querySelector(".terminal:not(.xterm):not([hidden]) .xterm-rows"); return r && /^e2e-feed-two\s*$/m.test(r.innerText) ? true : null`),
+ "the changed snippet's output",
+ 30000,
+ );
+ // Sent once as it is now: the next run doesn't ask.
+ await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Snippets")).click(); return true`);
+ await s.wait(() => s.hasText(".panel .lname", "E2E hello"), "the Snippets page");
+ await runFeedSnippet();
+ await new Promise((r) => setTimeout(r, 1000));
+ assert.ok(!(await s.exec(`return !!document.querySelector("dialog.snippet-check[open]")`)), "not asked again");
+ await s.click(".tab.active .close");
+ ok("the feed's snippet ran, read-only with a copy offered; once the feed changed it, running it asked first (and only once)");
+
// Several hosts at once: the CSV and Royal TS ones (nothing later uses them) into one group,
// then both deleted.
await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Hosts")).click(); return true`);
@@ -613,8 +765,8 @@ try {
await s.clickButton("Select");
const pick = (label) =>
s.exec(
- `const li = [...document.querySelectorAll(".panel.page li.picking")].find((l) => l.querySelector("strong")?.innerText === arguments[0]);
- if (!li) throw new Error("no row " + arguments[0]); li.click(); return true`,
+ `const row = [...document.querySelectorAll(".panel.page [role=row].picking")].find((r) => r.querySelector(".lname")?.innerText === arguments[0]);
+ if (!row) throw new Error("no row " + arguments[0]); row.click(); return true`,
label,
);
await pick("e2e-csv");
@@ -625,8 +777,8 @@ try {
await s.waitText("Moved 2 hosts.", ".select-bar-wrap", 30000);
await s.wait(
() =>
- s.exec(`const section = [...document.querySelectorAll(".panel.page section")].find((x) => x.querySelector(".group-title")?.innerText.includes("E2E Lab"));
- const names = section ? [...section.querySelectorAll("li strong")].map((x) => x.innerText) : [];
+ s.exec(`const section = [...document.querySelectorAll(".panel.page .lsection")].find((x) => x.querySelector(".lghead")?.innerText.includes("E2E Lab"));
+ const names = section ? [...section.querySelectorAll(".lname")].map((x) => x.innerText) : [];
return names.includes("e2e-csv") && names.includes("e2e-royal") || null`),
"both hosts in E2E Lab",
);
@@ -843,6 +995,13 @@ try {
assert.match(await openAbout(s), new RegExp(`Version ${version} · AppImage`));
ok(`About: ${version}, AppImage`);
+ // Its documentation and its source code, in the browser (browser.sh notes what it's given).
+ await s.clickButton("Documentation ↗");
+ await s.clickButton("Source code on GitHub ↗");
+ const opened = () => (existsSync(process.env.E2E_BROWSER_LOG) ? readFileSync(process.env.E2E_BROWSER_LOG, "utf8") : "");
+ await s.wait(() => (/^https:\/\/morphgate\.com\/docs$/m.test(opened()) && /^https:\/\/github\.com\/Vallic\/MorphGate$/m.test(opened())) || null, "the documentation and the source code opened");
+ ok("About opens the documentation and the source code");
+
// The third-party notices that come with the app: a crate's, from the generated list.
await s.clickButton("Open-source licenses");
await s.wait(() => s.exec(`return document.querySelector(".licenses-list li") ? true : null`), "the license list");
@@ -858,7 +1017,7 @@ try {
// A tampered download is refused and the AppImage stays as it was.
const before = sha(app);
copyFileSync(`${feed}/latest-tampered.json`, `${feed}/latest.json`);
- await s.clickButton("Check now");
+ await checkNow(s);
await s.waitText(`MorphGate ${next} is available`);
await s.clickButton("Install and restart");
assert.match(await s.wait(s.panelError, "the refusal", 120000), /signature/);
@@ -876,7 +1035,7 @@ try {
// The signed update installs, and the app restarts by itself.
copyFileSync(`${feed}/latest-good.json`, `${feed}/latest.json`);
- await s.clickButton("Check now");
+ await checkNow(s);
await s.waitText(`MorphGate ${next} is available`);
await s.screenshot(`${shots}/update-available.png`);
await s.clickButton("Install and restart");
@@ -918,8 +1077,11 @@ try {
await s.type("input[autocomplete=current-password]", PASSWORD);
await s.exec(`document.querySelector("form button[type=submit]").click(); return true`);
await s.wait(() => s.exec(`return document.querySelector("button.brand") ? true : null`), "the workspace", 60000);
+ // A vault with hosts opens on them (with none, on quick connect).
+ await s.wait(() => s.exec(`return document.querySelectorAll(".sidebar .row-title").length > 0 || null`), "the vault's hosts", 30000);
+ await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === "Hosts" || null`), "the Hosts page as home");
assert.match(await openAbout(s), new RegExp(`Version ${next} · AppImage`));
- await s.clickButton("Check now");
+ await checkNow(s);
await s.waitText("up to date");
await s.screenshot(`${shots}/updated.png`);
ok(`unlocked the same vault in ${next}, which is up to date`);
diff --git a/apps/desktop/e2e/browser.sh b/apps/desktop/e2e/browser.sh
index 12f752e..f1bb59a 100755
--- a/apps/desktop/e2e/browser.sh
+++ b/apps/desktop/e2e/browser.sh
@@ -1,4 +1,8 @@
#!/bin/sh
-# What the app opens sign-in pages with during e2e (MORPHGATE_BROWSER): follows the redirects
-# like a browser, from the identity provider back to the sync server.
-exec curl -sSL --max-time 30 -o /dev/null "$1"
+# What the app opens web pages with during e2e (MORPHGATE_BROWSER): follows the redirects like a
+# browser, from the identity provider back to the sync server. Pages off this computer (the docs,
+# GitHub) aren't fetched; every address is noted in E2E_BROWSER_LOG when it's set.
+[ -n "${E2E_BROWSER_LOG:-}" ] && echo "$1" >>"$E2E_BROWSER_LOG"
+case "$1" in
+ http://127.0.0.1[:/]* | http://localhost[:/]*) exec curl -sSL --max-time 30 -o /dev/null "$1" ;;
+esac
diff --git a/apps/desktop/e2e/demo.mjs b/apps/desktop/e2e/demo.mjs
new file mode 100644
index 0000000..f8f1805
--- /dev/null
+++ b/apps/desktop/e2e/demo.mjs
@@ -0,0 +1,132 @@
+// The README's demo: the packaged AppImage with a few hosts in groups. Its Hosts page is captured,
+// web-1 (the test sshd) is clicked, and its session view is captured and recorded with the app's
+// own session recording; tools/readme-demo/demo-svg.mjs draws it all (demo.json) as an animated
+// SVG. The host's login command runs a clean bash with a demo prompt in a demo home (a small git
+// repository); this machine's user and loopback address are swapped for demo ones in the captured
+// text, and nothing is saved that still shows them or this computer's home folder.
+//
+// node demo.mjs
+
+import { execFileSync } from "node:child_process";
+import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { userInfo } from "node:os";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { createVault, session } from "./webdriver.mjs";
+
+const [app, out, key, port, user] = process.argv.slice(2);
+const PASSWORD = "correct horse battery staple";
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const snapshotJs = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../../tools/readme-demo/snapshot.js"), "utf8");
+
+// The demo home: an app with a few releases.
+const home = `${out}/home-demo`;
+const repo = `${home}/shop`;
+mkdirSync(`${repo}/config`, { recursive: true });
+const git = (...args) =>
+ execFileSync("git", ["-C", repo, ...args], {
+ env: { ...process.env, GIT_AUTHOR_NAME: "Ana", GIT_AUTHOR_EMAIL: "ana@example.com", GIT_COMMITTER_NAME: "Ana", GIT_COMMITTER_EMAIL: "ana@example.com" },
+ });
+git("init", "-q", "-b", "main");
+for (const [file, text, message] of [
+ ["README.md", "# shop\n", "Start the shop"],
+ ["config/nginx.conf", "server { listen 80; }\n", "Serve it with nginx"],
+ ["Dockerfile", "FROM php:8.4-fpm\n", "Run in a container"],
+ ["config/nginx.conf", "server { listen 443 ssl; }\n", "TLS everywhere"],
+]) {
+ writeFileSync(`${repo}/${file}`, text);
+ git("add", "-A");
+ git("commit", "-q", "-m", message);
+}
+git("tag", "v2.4.1");
+
+// sshd starts it in the real home: it moves to the demo's first. A clean bash: no rc files, a
+// demo prompt (the theme's green and blue), this home.
+const login =
+ `cd '${home}' && exec env -i TERM=xterm-256color LANG=C.UTF-8 HOME='${home}' PATH=/usr/local/bin:/usr/bin:/bin GIT_PAGER=cat ` +
+ `PS1='\\[\\e[32m\\]deploy@web-1\\[\\e[0m\\]:\\[\\e[34m\\]\\w\\[\\e[0m\\]$ ' bash --norc --noprofile -i`;
+// What the captured text shows instead of this machine's.
+const mask = [[`${user}@127.0.0.1:${port}`, "deploy@10.0.12.34:22"]];
+
+let s = await session(app);
+try {
+ await createVault(s, PASSWORD);
+ await s.resize(1024, 600);
+ const invoke = (cmd, args) => s.exec(`return window.__TAURI_INTERNALS__.invoke(arguments[0], arguments[1])`, cmd, args);
+ const capture = () => s.exec(`${snapshotJs}; return snapshot(arguments[0]);`, mask);
+ const k = await invoke("key_import", { input: { label: "deploy", source: { kind: "file", path: key }, passphrase: null } });
+ const group = async (name) => (await invoke("group_save", { input: { name } })).id;
+ const production = await group("Production");
+ const staging = await group("Staging");
+ const host = (label, hostname, groupId, tags, extra = {}) =>
+ invoke("host_save", { input: { label, hostname, port: 22, user: "deploy", auth: { kind: "key", keyId: k.id }, groupId, tags, ...extra } });
+ await host("web-1", "127.0.0.1", production, ["shop"], { port: Number(port), user, recordSessions: true, loginCommand: login });
+ await host("web-2", "10.0.12.35", production, ["shop"]);
+ await host("db-1", "10.0.12.40", production, ["postgres"]);
+ await host("staging", "10.0.20.11", staging, ["shop"]);
+ await host("bastion", "bastion.shop.example", null, []);
+ // Made behind the page's back: it reloads its lists on vault-changed.
+ await invoke("plugin:event|emit", { event: "vault-changed", payload: null });
+ const sidebarHost = (label) =>
+ `[...document.querySelectorAll(".sidebar .list li")].find((l) => l.querySelector(".row-title")?.innerText === ${JSON.stringify(label)})`;
+ await s.wait(() => s.exec(`return ${sidebarHost("web-1")} ? true : null`), "web-1 listed");
+
+ // Its host key, pinned once beforehand: the demo's connection asks nothing.
+ const rows = () => s.exec(`return document.querySelector(".terminal:not(.xterm):not([hidden]) .xterm-rows")?.innerText ?? ""`);
+ const prompts = async () => ((await rows()).match(/deploy@web-1:/g) ?? []).length;
+ await s.exec(`${sidebarHost("web-1")}.querySelector(".row-main").click(); return true`);
+ await s.waitText("Trust and connect", "body", 60000);
+ await s.clickButton("Trust and connect");
+ await s.wait(async () => ((await prompts()) >= 1 ? true : null), "the demo prompt", 60000);
+ await s.click(".tab.active .close");
+
+ // Scene one: the Hosts page.
+ await s.exec(`[...document.querySelectorAll(".sidebar-nav button")].find((b) => b.innerText.startsWith("Hosts")).click(); return true`);
+ await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === "Hosts" || null`), "the Hosts page");
+ await sleep(800);
+ const row = `[...document.querySelectorAll(".panel.page .lrow")].find((r) => r.querySelector(".lname")?.innerText === "web-1").querySelector(".lnamebtn")`;
+ const target = await s.exec(`const r = ${row}.getBoundingClientRect(); return { x: r.left, y: r.top, w: r.width, h: r.height }`);
+ const s1 = await capture();
+ await s.screenshot(`${out}/scene1.png`);
+
+ // Scene two: the click, and the session.
+ await s.exec(`${row}.click(); return true`);
+ await s.wait(async () => ((await prompts()) >= 1 ? true : null), "the demo prompt", 60000);
+ await sleep(600);
+ const s2 = await capture();
+ await s.screenshot(`${out}/scene2.png`);
+ if (!s2.terminal) throw new Error("no terminal in the session view");
+ await sleep(600);
+ // Typed as a person would: a key at a time, a pause before Enter.
+ for (const command of ["uptime", "cd shop && git log --oneline --decorate -4", "ls"]) {
+ const before = await prompts();
+ for (const ch of command) {
+ await s.typeTerminal(ch);
+ await sleep(45 + Math.round(Math.random() * 70));
+ }
+ await sleep(450);
+ await s.typeTerminal("\r");
+ await s.wait(async () => ((await prompts()) > before ? true : null), `the prompt after ${command}`, 30000);
+ await sleep(1400);
+ }
+ await sleep(1500);
+ // Closing the tab ends the session and its recording.
+ await s.click(".tab.active .close");
+ const log = await s.wait(async () => {
+ const logs = (await invoke("session_logs", { hostId: null })).filter((l) => l.endedAt);
+ return logs.length >= 2 ? logs.sort((a, b) => b.startedAt - a.startedAt)[0] : null;
+ }, "the recording, ended", 30000);
+ const cast = await invoke("session_log_cast", { id: log.id });
+ const demo = JSON.stringify({ s1, s2, target, cast });
+ // It goes in a public README: nothing of this computer's.
+ for (const leak of [userInfo().homedir, "127.0.0.1", `${user}@`]) {
+ if (demo.includes(leak)) throw new Error(`the demo shows ${leak}: not saved`);
+ }
+ writeFileSync(`${out}/demo.json`, demo);
+ console.log(` ✓ captured the demo: ${out}/demo.json`);
+} catch (e) {
+ await s.screenshot(`${out}/failed.png`).catch(() => {});
+ throw e;
+} finally {
+ await s.close();
+}
diff --git a/apps/desktop/e2e/flows.mjs b/apps/desktop/e2e/flows.mjs
index 50953d5..f392309 100644
--- a/apps/desktop/e2e/flows.mjs
+++ b/apps/desktop/e2e/flows.mjs
@@ -7,6 +7,7 @@
// mails sign-up codes (Mailpit's web address), for servers that verify addresses.
import assert from "node:assert/strict";
+import { createHmac } from "node:crypto";
export const PASSWORD = "correct horse battery staple";
@@ -97,10 +98,10 @@ export async function signUp(s, { server, email, mailpit, password = PASSWORD })
await s.waitText("Last sync", ".panel", 60000);
}
-/** J3: the first screen's "I have an account" (a fresh device). */
+/** J3: the first screen's "Sign in" (a fresh device). */
export async function signInFresh(s, { server, email, password = PASSWORD }) {
await s.wait(() => s.exec(`return document.querySelector(".gate-choice") ? true : null`), "the first screen");
- await s.exec(`[...document.querySelectorAll(".gate-choice button")].find((b) => b.innerText === "I have an account").click(); return true`);
+ await s.exec(`[...document.querySelectorAll(".gate-choice button")].find((b) => b.innerText === "Sign in").click(); return true`);
await chooseServer(s, server);
await s.typeLabelled("Email", email);
await s.typeLabelled("Master password", password);
@@ -112,7 +113,7 @@ export async function signInFresh(s, { server, email, password = PASSWORD }) {
export async function openTeams(s) {
await s.click("button.sidebar-foot.teams");
await s.exec(
- `const b = [...document.querySelectorAll(".panel button.link")].find((b) => b.innerText.includes("All organizations"));
+ `const b = [...document.querySelectorAll(".panel button.link")].find((b) => b.innerText.trim() === "← Teams");
if (b) b.click(); return true`,
);
await s.waitText("Your fingerprint", ".panel");
@@ -120,8 +121,8 @@ export async function openTeams(s) {
export async function openOrg(s, name) {
await s.exec(
- `const li = [...document.querySelectorAll(".panel .device-list li")].find((l) => l.querySelector("strong")?.innerText === arguments[0]);
- li.querySelector("button").click(); return true`,
+ `const row = [...document.querySelectorAll(".panel [role=row]")].find((r) => r.querySelector(".lname")?.innerText === arguments[0]);
+ row.querySelector("button.lnamebtn").click(); return true`,
name,
);
await s.waitText("Members", ".panel");
@@ -131,12 +132,16 @@ export async function openOrg(s, name) {
export async function createTeam(s, name, vault) {
await openTeams(s);
const fp = await fingerprint(s);
- await s.type('input[aria-label="Organization name"]', name);
- await s.clickButton("Create");
+ await s.clickButton("New team");
+ await s.type('input[aria-label="Team name"]', name);
+ await s.clickButton("Create team");
+ await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === arguments[0] || null`, name), "the team's page");
+ await s.pageTab("Vaults");
await s.waitText("No vaults yet", ".panel");
- await s.type('input[aria-label="New vault name"]', vault);
await s.clickButton("New vault");
- await s.wait(() => s.hasText(".panel .device-list strong", vault), "the new vault");
+ await s.type('input[aria-label="Vault name"]', vault);
+ await s.clickButton("Create vault");
+ await s.wait(() => s.hasText(".panel .lname", vault), "the new vault");
return fp;
}
@@ -162,19 +167,22 @@ export async function mailedInvite(mailpit, to, since, ms = 30000) {
* the mail (Mailpit) where the server mails it.
*/
export async function invite(s, email, { mailpit } = {}) {
- await s.type('input[aria-label="Email to invite"]', email);
+ await s.clickButton("Invite people");
+ await s.wait(() => s.exec(`return document.querySelector("dialog.invite-dialog[open]") ? true : null`), "the invite dialog");
+ await s.type('input[aria-label="Emails to invite"]', email);
for (const box of await s.exec(`return document.querySelectorAll(".grants input[type=checkbox]").length`).then((n) => [...Array(n).keys()])) {
await s.exec(`document.querySelectorAll(".grants input[type=checkbox]")[arguments[0]].click(); return true`, box);
}
const since = Date.now();
- await s.clickButton("Invite");
+ await s.clickButton("Send invite");
const shown = await s.wait(
() =>
s.exec(
- `const c = document.querySelector(".invite-code"); return c ? { token: c.innerText.trim() } : document.querySelector(".panel").innerText.includes("Invite emailed to") ? { mailed: true } : null`,
+ `const c = document.querySelector(".invite-code"); return c ? { token: c.innerText.trim() } : document.querySelector("dialog.invite-dialog")?.innerText.includes("Invite emailed to") ? { mailed: true } : null`,
),
"the invite code, or that it was mailed",
);
+ await s.clickButton("Done");
if (shown.token) return shown.token;
assert.ok(mailpit, "the server mails invites: pass where to read them (Mailpit)");
return mailedInvite(mailpit, email, since);
@@ -183,10 +191,16 @@ export async function invite(s, email, { mailpit } = {}) {
/** T2 (the invitee): joins after checking the owner's fingerprint. Returns the member's own. */
export async function acceptInvite(s, token, ownerFp, team) {
await openTeams(s);
- const fp = await fingerprint(s);
await s.type('input[aria-label="Invite code"]', token);
await s.clickButton("Look up");
- await s.waitText("Join only if", ".panel");
+ return joinLookedUp(s, ownerFp, team);
+}
+
+/** T2 (the invitee, in Teams with the invite looked up): compares the owner's fingerprint and
+ * joins. Returns the member's own. */
+export async function joinLookedUp(s, ownerFp, team) {
+ await s.waitText("Join only if", ".panel", 30000);
+ const fp = await fingerprint(s, ".teams-foot > .stack > .fingerprint");
assert.equal(await fingerprint(s, ".notice-box .fingerprint"), ownerFp);
await s.click(".notice-box label.check input");
await s.clickButton(`Join ${team}`);
@@ -194,22 +208,72 @@ export async function acceptInvite(s, token, ownerFp, team) {
return fp;
}
+/**
+ * J2 and T2 on a fresh install: the invite pasted on the first screen ("Joining a team?"), a new
+ * account with the address it names and a suggested password, then Teams opens with the invite
+ * looked up. Returns the password.
+ */
+export async function newAccountFromInvite(s, { invite, team, email, mailpit, shots }) {
+ await s.wait(() => s.exec(`return document.querySelector(".gate-choice") ? true : null`), "the first screen");
+ await s.clickButton("Joining a team? Paste your invite");
+ await s.typeLabelled("Invite code or link", invite);
+ await s.clickButton("Use this invite");
+ await s.waitText(`to join ${team}`, ".gate-invite", 30000);
+ // An invite needs an account: offered first, with the invited address.
+ await s.waitText("Create your account", ".gate-card");
+ await s.wait(() => s.exec(`return document.querySelector(".gate-card input[type=email]")?.value === arguments[0] || null`, email), "the invited address");
+ await s.clickButton("Suggest a strong password");
+ const password = await s.wait(() => s.exec(`return document.querySelector(".suggested-password .mono")?.innerText || null`), "a suggested password");
+ if (shots) await s.screenshot(`${shots}/first-screen-invite.png`);
+ // The server's terms, once it was asked what it needs (none on a server without them).
+ await s.wait(() => s.exec(`return document.querySelector(".gate-card .terms-check") ? true : null`), "the terms", 10000).catch(() => {});
+ if (await s.exec(`return !!document.querySelector(".gate-card .terms-check input")`)) await s.click(".gate-card .terms-check input");
+ // The recovery key (the account is made), an error, or (`code`: the first step) the mailed code's field.
+ const step = (code, what, ms) =>
+ s.wait(
+ () =>
+ s.exec(
+ `const e = document.querySelector(".gate-card p.error"); if (e) return { error: e.innerText };
+ if (document.querySelector(".recovery-code")) return "done";
+ return arguments[0] && [...document.querySelectorAll(".gate-card label span")].some((l) => l.innerText.startsWith("Code sent to")) ? "code" : null`,
+ code,
+ ),
+ what,
+ ms,
+ );
+ const since = Date.now();
+ await s.click(".gate-card form button.primary");
+ let next = await step(true, "a mailed code, or the account", 60000);
+ if (next === "code") {
+ assert.ok(mailpit, "the server mails a code: pass where to read it (Mailpit)");
+ await s.typeLabelled("Code sent to", await mailedCode(mailpit, email, since));
+ await s.click(".gate-card form button.primary");
+ next = await step(false, "the account", 90000);
+ }
+ assert.equal(next, "done", JSON.stringify(next));
+ await s.click(".gate-card label.check input");
+ await s.exec(`[...document.querySelectorAll(".gate-card button.primary")].find((b) => !b.disabled).click(); return true`);
+ await s.wait(() => s.exec(`return document.querySelector("button.brand, .m-home") ? true : null`), "the workspace", 30000);
+ return password;
+}
+
/** T2 (an owner or admin, in the organization's page): confirms `email` after checking its fingerprint. */
export async function confirmMember(s, email, memberFp) {
- await s.waitText("Waiting for confirmation", ".panel", 30000);
+ await s.pageTab("Members");
+ await s.waitText("To confirm", ".panel", 30000);
await s.clickButton("Confirm…");
- assert.equal(await fingerprint(s, "li.nested .fingerprint"), memberFp);
- await s.click("li.nested label.check input");
+ assert.equal(await fingerprint(s, ".lexpand .fingerprint"), memberFp);
+ await s.click(".lexpand label.check input");
await s.clickButton(`Confirm ${email}`);
- await s.wait(() => s.exec(`return document.querySelector(".panel").innerText.includes("Waiting for confirmation") ? null : true`), "the confirmation", 30000);
+ await s.wait(() => s.exec(`return document.querySelector(".panel").innerText.includes("To confirm") ? null : true`), "the confirmation", 30000);
}
/** T6 (an owner or admin, in the organization's page): removes `email` and rotates keys. */
export async function removeMember(s, email) {
- // The "Remove…" on `email`'s row (the smallest list item naming them that has one).
- const row = `[...document.querySelectorAll(".panel li")]
- .filter((l) => l.innerText.includes(arguments[0]) && [...l.querySelectorAll("button")].some((b) => b.innerText.includes("Remove…")))
- .sort((a, b) => a.innerText.length - b.innerText.length)[0]`;
+ // The "Remove…" on `email`'s row in the member list.
+ await s.pageTab("Members");
+ const row = `[...document.querySelectorAll(".panel [role=row]")]
+ .find((r) => r.querySelector(".lname")?.innerText === arguments[0] && [...r.querySelectorAll("button")].some((b) => b.innerText.includes("Remove…")))`;
await s.wait(() => s.exec(`return ${row} ? true : null`, email), `${email} in the member list`);
await s.exec(`[...${row}.querySelectorAll("button")].find((b) => b.innerText.includes("Remove…")).click(); return true`, email);
await s.waitText("1. What they can open", ".panel");
@@ -335,7 +399,8 @@ export async function signInAgain(s, password = PASSWORD) {
/** T8 (the owner, in the organization's page): deletes it. */
export async function deleteTeam(s, name, password = PASSWORD) {
- await s.clickButton("Delete organization");
+ await s.pageTab("Settings");
+ await s.clickButton("Delete team…");
await s.typeLabelled("Your master password", password);
await clickWhenEnabled(s, `Delete ${name}`);
await s.waitText("Your fingerprint", ".panel", 60000);
@@ -346,6 +411,8 @@ export async function deleteTeam(s, name, password = PASSWORD) {
* confirmation reads "Really leave?"). `name` is unused, kept for callers that pass it.
*/
export async function leaveTeam(s, name) {
+ // A confirmed member leaves from their row in Members; one still waiting, from the notice.
+ if (await s.exec(`return !!document.querySelector(".page-tabs")`)) await s.pageTab("Members");
await s.clickButton("Leave");
await s.clickButton("Really leave");
await s.waitText("Your fingerprint", ".panel", 60000);
@@ -433,9 +500,6 @@ export async function createVaultKeepingKey(s, password = PASSWORD) {
await s.click("label.check input");
await s.exec(`[...document.querySelectorAll("button.primary")].find((b) => !b.disabled).click(); return true`);
await s.wait(() => s.exec(`return document.querySelector("button.brand, .m-home") ? true : null`), "the workspace", 30000);
- await s.wait(() => s.exec(`return document.querySelector("dialog.sync-offer[open]") ? true : null`), "the offer to turn sync on");
- await s.exec(`[...document.querySelectorAll("dialog.sync-offer button")].find((b) => b.innerText === "Not now").click(); return true`);
- await s.wait(() => s.exec(`return document.querySelector("dialog.sync-offer") ? null : true`), "the offer to close");
return key;
}
@@ -454,20 +518,21 @@ export async function recoverOnUnlock(s, key, password) {
/** In the organization's page: the vault named `vault`, its access list open. */
async function openAccess(s, vault) {
- await s.wait(() => s.hasText(".panel .device-list strong", vault), `the vault ${vault}`);
+ await s.pageTab("Vaults");
+ await s.wait(() => s.hasText(".panel .lname", vault), `the vault ${vault}`);
await s.exec(
- `const li = [...document.querySelectorAll(".panel .device-list li")].find((l) => l.querySelector("strong")?.innerText === arguments[0]);
- [...li.querySelectorAll("button")].find((b) => b.innerText.includes("Access")).click(); return true`,
+ `const row = [...document.querySelectorAll(".panel [role=row]")].find((r) => r.querySelector(".lname")?.innerText === arguments[0]);
+ [...row.querySelectorAll("button")].find((b) => b.innerText.includes("Access")).click(); return true`,
vault,
);
- await s.wait(() => s.exec(`return document.querySelector("li.nested .access-row") ? true : null`), "the access list");
+ await s.wait(() => s.exec(`return document.querySelector(".lexpand .access-row") ? true : null`), "the access list");
}
/** T4 (an owner or admin, in the organization's page): takes `email`'s access to `vault` away. */
export async function revokeAccess(s, vault, email) {
await openAccess(s, vault);
await s.exec(
- `const row = [...document.querySelectorAll("li.nested .access-row")].find((r) => r.innerText.includes(arguments[0]));
+ `const row = [...document.querySelectorAll(".lexpand .access-row")].find((r) => r.innerText.includes(arguments[0]));
row.querySelector("button.ghost-danger").click(); return true`,
email,
);
@@ -479,7 +544,7 @@ export async function grantAccess(s, vault, email) {
await openAccess(s, vault);
await s.choose('select[aria-label="Member to give access"]', email);
await s.exec(`const sel = document.querySelector('select[aria-label="Member to give access"]'); [...sel.closest(".row").querySelectorAll("button")].find((b) => b.innerText === "Add").click(); return true`);
- await s.wait(() => s.exec(`return [...document.querySelectorAll("li.nested .access-row")].some((r) => r.innerText.includes(arguments[0]) && r.querySelector("button.ghost-danger")) || null`, email), `${email}'s access`, 30000);
+ await s.wait(() => s.exec(`return [...document.querySelectorAll(".lexpand .access-row")].some((r) => r.innerText.includes(arguments[0]) && r.querySelector("button.ghost-danger")) || null`, email), `${email}'s access`, 30000);
}
/** After a removal or revocation: the rotation Teams asks for, done (every vault due). */
@@ -495,25 +560,71 @@ export async function rotateDueKeys(s, org) {
}
}
+/** An owner or admin, on the team's page: turns on the setting whose name has `text`, and saves. */
+async function turnOnPolicy(s, text) {
+ await s.pageTab("Settings");
+ const find = `[...document.querySelectorAll(".setting")].find((r) => r.querySelector(".setting-name")?.innerText.includes(arguments[0]))`;
+ await s.wait(() => s.exec(`return ${find} ? true : null`, text), "the settings");
+ await s.exec(`${find}.querySelector("button[role=switch]").click(); return true`, text);
+ await s.clickButton("Save settings");
+ await s.wait(() => s.exec(`return [...document.querySelectorAll("button")].some((b) => b.innerText.includes("Save settings")) ? null : true`), "the settings to save");
+}
+
/** J7 (an owner or admin, in the organization's page): requires approval of new devices. */
-export async function requireDeviceApproval(s) {
- await s.wait(
- () => s.exec(`return [...document.querySelectorAll(".page-aside label.check")].some((l) => l.innerText.includes("approve new devices")) || null`),
- "the policies",
+export const requireDeviceApproval = (s) => turnOnPolicy(s, "Approve new devices");
+
+/** T14 (an owner or admin, in the organization's page): requires two-step verification. */
+export const requireTwoStep = (s) => turnOnPolicy(s, "Require two-step verification");
+
+/** The sidebar's "Turn on two-step verification" (a team requires it), with its tooltip, or null. */
+export const twoStepDue = (s) => s.exec(`const b = document.querySelector("button.sidebar-foot.totp"); return b ? b.title : null`);
+
+/** The current code of an authenticator app holding `secret` (RFC 6238: SHA-1, 6 digits, 30 s). */
+export function totpCode(secret, at = Date.now()) {
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
+ const bits = [...secret.replace(/[\s=]/g, "").toUpperCase()].map((c) => alphabet.indexOf(c).toString(2).padStart(5, "0")).join("");
+ const key = Buffer.from(bits.match(/.{8}/g).map((b) => parseInt(b, 2)));
+ const counter = Buffer.alloc(8);
+ counter.writeBigUInt64BE(BigInt(Math.floor(at / 30000)));
+ const mac = createHmac("sha1", key).update(counter).digest();
+ const at4 = mac[mac.length - 1] & 15;
+ return String((mac.readUInt32BE(at4) & 0x7fffffff) % 1e6).padStart(6, "0");
+}
+
+/** T14: turns on two-step verification with a made-up authenticator, from the sidebar's notice
+ * when a team asks for it, else from Sync; returns its secret. */
+export async function turnOnTwoStep(s, password = PASSWORD) {
+ const press = (text) =>
+ s.exec(`const b = [...document.querySelectorAll(".panel button")].find((b) => b.innerText === arguments[0] && !b.disabled);
+ if (!b) throw new Error("no enabled button " + arguments[0]); b.click(); return true`, text);
+ if (await twoStepDue(s)) await s.click("button.sidebar-foot.totp");
+ else {
+ await s.click("button.sidebar-foot.sync");
+ await s.wait(() => s.exec(`return [...document.querySelectorAll(".panel button")].some((b) => b.innerText === "Turn on two-step verification") || null`), "the account");
+ await press("Turn on two-step verification");
+ }
+ await s.waitText("Confirm with your master password", ".panel");
+ await s.typeLabelled("Master password", password);
+ await press("Continue");
+ const secret = await s.wait(
+ () => s.exec(`const k = [...document.querySelectorAll(".panel .hint")].find((p) => p.innerText.includes("Enter this key instead")); return k ? k.querySelector(".mono").innerText : null`),
+ "the authenticator key",
+ 30000,
);
- await s.exec(`[...document.querySelectorAll(".page-aside label.check")].find((l) => l.innerText.includes("approve new devices")).querySelector("input").click(); return true`);
- await s.clickButton("Save policies");
- await s.wait(() => s.exec(`return [...document.querySelectorAll(".page-aside button")].some((b) => b.innerText.includes("Save policies")) ? null : true`), "the policies to save");
+ await s.typeLabelled("Authenticator code", totpCode(secret));
+ await press("Turn on");
+ await s.waitText("Turn off two-step verification", ".panel", 30000);
+ return secret.replace(/\s/g, "");
}
/** The device-approval state on this device's sidebar ("Waiting for approval", "Denied"), or null. */
export const approvalState = (s) => s.exec(`const b = document.querySelector("button.sidebar-foot.approval"); return b ? b.innerText : null`);
-/** J7 (an owner, an admin or one of the member's approved devices, in the organization's page): approves the waiting device. */
+/** J7 (an owner, an admin or one of the member's approved devices, in the organization's page): approves the waiting device, from Needs you. */
export async function approveDevice(s) {
- await s.waitText("Waiting for approval", ".panel.page", 60000);
- await s.exec(`[...document.querySelectorAll(".device-requests button")].find((b) => b.innerText === "Approve").click(); return true`);
- await s.wait(() => s.exec(`return document.querySelector(".device-requests") ? null : true`), "the request to go", 30000);
+ await s.waitText("waits for approval", ".needs", 60000);
+ await s.exec(`[...document.querySelectorAll(".needs button")].find((b) => b.innerText === "Approve").click(); return true`);
+ await s.wait(() => s.exec(`return [...document.querySelectorAll(".needs button")].some((b) => b.innerText === "Approve") ? null : true`), "the request to go", 30000);
}
/** S9: Sync → Delete account, with the master password. */
@@ -530,6 +641,7 @@ export async function deleteAccount(s, password = PASSWORD) {
export async function memberListed(s, org, email) {
await openTeams(s);
await openOrg(s, org);
+ await s.pageTab("Members");
await s.wait(() => s.exec(`return document.querySelector(".panel").innerText.includes("Members") || null`), "the member list");
await new Promise((r) => setTimeout(r, 1500));
return s.exec(`return document.querySelector(".panel").innerText.includes(arguments[0])`, email);
diff --git a/apps/desktop/e2e/host-feed.mjs b/apps/desktop/e2e/host-feed.mjs
new file mode 100644
index 0000000..343a2ac
--- /dev/null
+++ b/apps/desktop/e2e/host-feed.mjs
@@ -0,0 +1,42 @@
+// A stand-in feed (docs/features/cloud-sources.md, "Feeds"): hosts and snippets a team publishes,
+// read with a token. Its host "e2e-feed-web" is the test sshd on 127.0.0.4, with the sshd's host
+// key listed (so nothing asks to trust it); "e2e-feed-wrong" is the same server listed with
+// another key (refused); "broken" is skipped. POST /e2e/next changes the snippet and drops the
+// wrong host, as a publisher's next version would. The token is E2E_HOST_FEED_TOKEN.
+//
+// node host-feed.mjs
+
+import { readFileSync } from "node:fs";
+import { createServer } from "node:http";
+
+const [port, hostKeyFile, sshPort, user] = process.argv.slice(2);
+const TOKEN = process.env.E2E_HOST_FEED_TOKEN;
+const hostKey = readFileSync(hostKeyFile, "utf8").trim().split(/\s+/).slice(0, 2).join(" ");
+const otherKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDRbMaf7RI+oSkPOCPrFjPoMWJEIu96Drte4Q3r9OQYR";
+let version = 1;
+
+const doc = () => ({
+ version: 1,
+ name: "E2E operations",
+ hosts: [
+ { id: "web", label: "e2e-feed-web", hostname: "127.0.0.4", port: Number(sshPort), user, group: "E2E feed", tags: ["feed"], key: "e2e_key", host_keys: [hostKey] },
+ ...(version === 1 ? [{ id: "wrong", label: "e2e-feed-wrong", hostname: "127.0.0.4", port: Number(sshPort), user, group: "E2E feed", key: "e2e_key", host_keys: [otherKey] }] : []),
+ { id: "broken", label: "broken", hostname: "-oProxyCommand=touch /tmp/x", user },
+ ],
+ snippets: [{ id: "hello", name: "E2E hello", body: version === 1 ? "echo e2e-feed-one" : "echo e2e-feed-two", tags: ["feed"] }],
+});
+
+createServer((req, res) => {
+ const send = (status, json) => {
+ res.writeHead(status, { "content-type": "application/json" });
+ res.end(JSON.stringify(json));
+ };
+ console.log(req.method, req.url, req.headers["user-agent"]);
+ if (req.method === "POST" && req.url === "/e2e/next") {
+ version = 2;
+ return send(200, { version });
+ }
+ if (req.url !== "/morphgate.json") return send(404, {});
+ if (req.headers.authorization !== `Bearer ${TOKEN}`) return send(401, { message: "Unauthenticated." });
+ return send(200, doc());
+}).listen(Number(port), "127.0.0.1");
diff --git a/apps/desktop/e2e/layout.mjs b/apps/desktop/e2e/layout.mjs
index c61aa4b..ce1c970 100644
--- a/apps/desktop/e2e/layout.mjs
+++ b/apps/desktop/e2e/layout.mjs
@@ -1,12 +1,13 @@
// The pages at several window widths: screenshots, and a check that nothing on them overlaps or
-// runs out of its box. Sync is turned on from the offer a new vault gets; then a key, hosts in a
-// group, a snippet, a tunnel and a team with a vault and an open invite fill the pages.
+// runs out of its box. Sync is turned on from the offer a vault without it gets a week on; then a
+// key, hosts in a group, a snippet, a tunnel and a team with a vault and an open invite fill the
+// pages.
//
// session.sh node layout.mjs
import assert from "node:assert/strict";
import { mkdirSync } from "node:fs";
-import { createVault, session } from "./webdriver.mjs";
+import { createVault, reload, session } from "./webdriver.mjs";
const [app, shots, server] = process.argv.slice(2);
const PASSWORD = "correct horse battery staple";
@@ -62,13 +63,20 @@ async function page(s, label) {
const s = await session(app);
try {
- // The first screen: a new vault, or the account used on other devices.
+ // The first screen: a new vault, a new account, or the account used on other devices.
await s.wait(() => s.exec(`return document.querySelector(".gate-choice") ? true : null`), "the first screen");
- await s.exec(`[...document.querySelectorAll(".gate-choice button")].find((b) => b.innerText === "I have an account").click(); return true`);
+ await s.exec(`[...document.querySelectorAll(".gate-choice button")].find((b) => b.innerText === "New account").click(); return true`);
+ await s.waitText("Create your account");
+ await s.clickButton("Suggest a strong password");
+ await s.wait(() => s.exec(`return document.querySelector(".suggested-password") ? true : null`), "a suggested password");
+ await shoot(s, "first-run-new-account", ".gate");
+ await s.exec(`[...document.querySelectorAll(".gate-choice button")].find((b) => b.innerText === "Sign in").click(); return true`);
await s.waitText("Sign in to sync");
await shoot(s, "first-run-sign-in", ".gate");
await s.exec(`[...document.querySelectorAll(".gate-choice button")].find((b) => b.innerText === "New vault").click(); return true`);
- await createVault(s, PASSWORD, shots, { keepOffer: true });
+ await createVault(s, PASSWORD, shots);
+ await reload(s, { weekLater: true });
+ await s.wait(() => s.exec(`return document.querySelector("dialog.sync-offer[open]") ? true : null`), "the offer to turn sync on");
await shoot(s, "offer", "dialog.sync-offer");
await s.exec(`[...document.querySelectorAll('dialog.sync-offer .sync-server button[role="radio"]')].find((b) => b.innerText === "Your own server").click(); return true`);
await s.typeLabelled("Server address", server);
@@ -77,7 +85,7 @@ try {
await s.clickButton("Enable sync");
await s.wait(() => s.exec(`return document.querySelector("dialog.sync-offer") ? null : true`), "the offer to close", 60000);
await s.wait(() => s.exec(`return document.querySelector("button.sidebar-foot.sync")?.innerText.includes("Sync off") ? null : true`), "sync on");
- ok("turned sync on from the first-run offer");
+ ok("turned sync on from the offer a week after the vault was created");
// The tab bar gives way to a page while no session is open.
await s.click("button.sidebar-foot.sync");
@@ -126,14 +134,14 @@ try {
await s.waitText("selected", ".select-bar");
await shoot(s, "hosts-selecting");
await s.clickButton("Done");
- await s.clickButton("Clouds…");
+ await s.addFrom("Clouds…");
await s.waitText("Connect Vallic Cloud", ".panel");
await shoot(s, "vallic-cloud");
await s.clickButton("Close");
// Groups, a group's page with everything in it, and its editor full screen.
const groupPage = async () => {
- await s.exec(`[...document.querySelectorAll(".panel button.title-link")].find((b) => b.innerText === "Production").click(); return true`);
+ await s.exec(`[...document.querySelectorAll(".panel button.lnamebtn")].find((b) => b.querySelector(".lname")?.innerText === "Production").click(); return true`);
await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === "Production" || null`), "the Production group's page");
};
await page(s, "Groups");
@@ -154,8 +162,14 @@ try {
await s.click(".panel form button[type=submit]");
await s.waitText("Save changes", ".panel");
await s.clickButton("Close");
- await s.wait(() => s.hasText(".panel strong", "Tail nginx errors"), "the snippet on its page");
+ await s.wait(() => s.hasText(".panel .lname", "Tail nginx errors"), "the snippet on its page");
await shoot(s, "snippets");
+ // A page's own page (its import) closes back to it, not home.
+ await s.clickButton("Import…");
+ await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === "Import snippets" || null`), "Import snippets");
+ await s.click(".panel.page .page-close");
+ await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === "Snippets" || null`), "back on Snippets");
+ ok("closing Import snippets returns to Snippets");
await page(s, "Tunnels");
await s.clickButton("New tunnel");
@@ -168,7 +182,7 @@ try {
await s.click(".panel form button[type=submit]");
await s.waitText("Edit tunnel", ".panel");
await s.clickButton("Cancel");
- await s.wait(() => s.hasText(".panel strong", "Postgres on prod"), "the tunnel on its page");
+ await s.wait(() => s.hasText(".panel .lname", "Postgres on prod"), "the tunnel on its page");
await shoot(s, "tunnels");
await page(s, "VPNs");
@@ -180,26 +194,51 @@ try {
await s.typeLabelled("Server", "vpn.client.example");
await s.click(".panel form button[type=submit]");
await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === "VPNs" || null`), "the VPNs page again");
- await s.wait(() => s.hasText(".panel strong", "Client office"), "the VPN on its page");
+ await s.wait(() => s.hasText(".panel .lname", "Client office"), "the VPN on its page");
await shoot(s, "vpns");
await page(s, "Keys");
await shoot(s, "keys");
// A team with a vault and an invite to a long address.
await s.click("button.sidebar-foot.teams");
- await s.wait(() => s.exec(`return document.querySelector('input[aria-label="Organization name"]') ? true : null`), "the teams page");
+ await s.wait(() => s.exec(`return [...document.querySelectorAll(".panel button")].some((b) => b.innerText === "New team") || null`), "the teams page");
await shoot(s, "teams-list");
- await s.type('input[aria-label="Organization name"]', "Acme Operations International");
- await s.clickButton("Create");
+ // Cancel puts the New team bar away and stays on Teams.
+ await s.clickButton("New team");
+ await s.wait(() => s.exec(`return document.querySelector('input[aria-label="Team name"]') ? true : null`), "the New team bar");
+ await shoot(s, "teams-new");
+ await s.click("form.quick-bar button.quiet");
+ await s.wait(() => s.exec(`return document.querySelector('input[aria-label="Team name"]') ? null : true`), "the bar to go");
+ assert.equal(await s.exec(`return document.querySelector(".panel.page h1")?.innerText`), "Teams");
+ ok("New team, then Cancel, stays on Teams");
+ await s.clickButton("New team");
+ await s.type('input[aria-label="Team name"]', "Acme Operations International");
+ await s.clickButton("Create team");
+ await s.wait(() => s.exec(`return document.querySelector(".panel.page h1")?.innerText === "Acme Operations International" || null`), "the team's page");
+ await s.pageTab("Vaults");
await s.waitText("No vaults yet", ".panel");
- await s.type('input[aria-label="New vault name"]', "Production databases");
await s.clickButton("New vault");
- await s.wait(() => s.hasText(".panel .device-list strong", "Production databases"), "the new vault");
- await s.type('input[aria-label="Email to invite"]', "someone.with.a.long.address@example.com");
+ await s.type('input[aria-label="Vault name"]', "Production databases");
+ await s.clickButton("Create vault");
+ await s.wait(() => s.hasText(".panel .lname", "Production databases"), "the new vault");
+ await shoot(s, "org-vaults");
+ await s.clickButton("Invite people");
+ await s.type('input[aria-label="Emails to invite"]', "someone.with.a.long.address@example.com, another.person@example.com");
await s.click(".grants input[type=checkbox]");
- await s.clickButton("Invite");
- await s.wait(() => s.exec(`return document.querySelector(".invite-code") ? true : null`), "the invite code");
+ await shoot(s, "invite", "dialog.invite-dialog");
+ await s.clickButton("Send 2 invites");
+ await s.wait(() => s.exec(`return document.querySelector(".invite-code") ? true : null`), "the invite codes");
+ await shoot(s, "invite-sent", "dialog.invite-dialog");
+ await s.clickButton("Done");
+ await s.pageTab("Members");
+ await s.wait(() => s.hasText(".panel .lname", "someone.with.a.long.address@example.com"), "the invites in Members");
await shoot(s, "org");
+ await s.pageTab("Settings");
+ await s.waitText("Require two-step verification", ".panel");
+ await shoot(s, "org-settings");
+ await s.pageTab("Activity");
+ await s.waitText("invited someone.with.a.long.address@example.com", ".panel");
+ await shoot(s, "org-activity");
// Moving the group into the team's vault: the plan, then cancel.
await page(s, "Groups");
diff --git a/apps/desktop/e2e/mobile.mjs b/apps/desktop/e2e/mobile.mjs
index 3334b57..1907e60 100644
--- a/apps/desktop/e2e/mobile.mjs
+++ b/apps/desktop/e2e/mobile.mjs
@@ -33,6 +33,13 @@ const tapText = (css, text) =>
try {
await s.resize(390, 844);
+ // The first screen's three choices fit a phone.
+ await s.wait(() => s.exec(`return document.querySelector(".gate-choice") ? true : null`), "the first screen", 60000);
+ assert.ok(
+ await s.exec(`return [...document.querySelectorAll(".gate-choice button")].every((b) => b.scrollWidth <= b.clientWidth && b.getBoundingClientRect().height < 40)`),
+ "the choices fit on one line",
+ );
+ await s.screenshot(`${shots}/first-screen.png`);
await createVault(s, PASSWORD, shots);
await s.wait(() => s.exec(`return document.querySelector(".m-home") ? true : null`), "the phone layout");
ok("a phone-sized window gets the phone layout");
@@ -200,8 +207,8 @@ try {
await tap('.m-sftp:not([hidden]) .m-bar button[aria-label="Back"]');
await tapText(".m-nav button", "More");
await tapText(".m-row", "Session logs");
- await s.wait(() => s.hasText(".panel.page .device-list strong", "box"), "the recording in Session logs");
- await tapText(".panel.page .device-list button", "Play");
+ await s.wait(() => s.hasText(".panel.page .lname", "box"), "the recording in Session logs");
+ await tapText(".panel.page .lrow .rowact button", "Play");
await s.wait(
async () => (await s.exec(`const r = document.querySelector(".player-screen .xterm-rows"); return r ? r.innerText : ""`)).includes("arrow-42") || null,
"the recording to play",
diff --git a/apps/desktop/e2e/monkey.mjs b/apps/desktop/e2e/monkey.mjs
new file mode 100644
index 0000000..b006eea
--- /dev/null
+++ b/apps/desktop/e2e/monkey.mjs
@@ -0,0 +1,231 @@
+// A monkey test of a packaged build: a vault with hosts, groups, keys and snippets, then a seeded
+// random walk through the app (clicks on whatever is there, typing, keys, selects, the window
+// resized), checking after every step that the page threw nothing, still renders and still
+// answers. A failure names the seed, the step and the last steps taken, with a screenshot.
+//
+// node monkey.mjs
+//
+// MONKEY_STEPS (500) and MONKEY_SEED (random, printed) repeat a run. Its hosts point where
+// nothing listens (no server a random walk could change files on), and what would end the walk
+// or leave the window (locking, installing an update, native file dialogs, erasing the device)
+// isn't clicked.
+
+import { mkdirSync, writeFileSync } from "node:fs";
+import { createVault, session } from "./webdriver.mjs";
+
+const [app, out, label] = process.argv.slice(2);
+const STEPS = Number(process.env.MONKEY_STEPS || 500);
+const SEED = Number(process.env.MONKEY_SEED || Math.floor(Math.random() * 2 ** 31));
+const PASSWORD = "correct horse battery staple";
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+mkdirSync(out, { recursive: true });
+
+// mulberry32: the same seed, the same walk.
+let state = SEED >>> 0;
+const random = () => {
+ state = (state + 0x6d2b79f5) >>> 0;
+ let t = state;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+};
+const pick = (list) => list[Math.floor(random() * list.length)];
+const between = (lo, hi) => lo + Math.floor(random() * (hi - lo + 1));
+
+/** Something a person might type, or a cat walking over the keyboard. */
+function gibberish() {
+ return pick([
+ () => Array.from({ length: between(1, 12) }, () => pick("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789")).join(""),
+ () => String(between(-99999, 99999)),
+ () => pick(["web-1", "db.example.com", "10.0.0.1", "::1", "user@host", "~/.ssh/id_ed25519", "https://x.example/feed", "*.example.com", "0", "-1", "65536"]),
+ () => Array.from({ length: between(1, 20) }, () => String.fromCharCode(between(32, 126))).join(""),
+ () => pick(["čćžšđ", "日本語のホスト", "émoji 🚀🔑", "RTL", "a\u0000b", "bold ", "' OR 1=1 --", "${HOME}", "%s%n", "\\\\server\\share"]),
+ () => "x".repeat(between(200, 2000)),
+ () => "",
+ ])();
+}
+
+// Clicked: anything that takes a click, unless it ends the walk or leaves the window.
+const CLICKABLE = "button, a[href], [role=tab], [role=menuitem], [role=radio], input[type=checkbox], summary, .lrow, .row-main, label.check";
+const AVOID =
+ /^(Lock|Install and restart|Quit)\b|lock (now|the vault)|erase|reset (this|the) (device|app)|delete (my|this|your) account|delete account|sign out everywhere|export|save \.cast|save .*csv|example csv|choose (a )?file|choose…|browse…|upload|download|open file|pick a file/i;
+
+const PAGE_HOOKS = `
+ if (!window.__monkey) {
+ window.__monkey = [];
+ window.addEventListener("error", (e) => window.__monkey.push("error: " + (e.message || e.error)));
+ window.addEventListener("unhandledrejection", (e) => window.__monkey.push("unhandled rejection: " + (e.reason?.message ?? e.reason)));
+ }
+ return true;`;
+
+const s = await session(app);
+const trail = [];
+const counts = {};
+let step = 0;
+
+/** What the walk does next, and what it did. */
+async function act() {
+ const roll = random();
+ if (roll < 0.56) {
+ // Something visible and enabled to click, by its index among them.
+ const n = await s.exec(
+ `const avoid = new RegExp(arguments[1], "i");
+ window.__targets = [...document.querySelectorAll(arguments[0])].filter((e) => {
+ const r = e.getBoundingClientRect();
+ if (!r.width || !r.height || e.disabled || e.closest("[inert], [aria-hidden=true]")) return false;
+ if (e.matches("input[type=file]") || e.closest("label")?.querySelector("input[type=file]")) return false;
+ const text = (e.getAttribute("aria-label") || e.innerText || e.title || "").trim();
+ return !avoid.test(text);
+ });
+ return window.__targets.length`,
+ CLICKABLE,
+ AVOID.source,
+ );
+ if (!n) return "click: nothing to click";
+ const i = Math.floor(random() * n);
+ return s.exec(
+ `const e = window.__targets[arguments[0]];
+ const what = (e.getAttribute("aria-label") || e.innerText || e.title || e.tagName).trim().replace(/\\s+/g, " ").slice(0, 60);
+ e.scrollIntoView({ block: "nearest" }); e.click(); return "click: " + what`,
+ i,
+ );
+ }
+ if (roll < 0.76) {
+ const text = gibberish();
+ const typed = await s.exec(
+ `const fields = [...document.querySelectorAll("input:not([type=checkbox]):not([type=radio]):not([type=file]), textarea")]
+ .filter((e) => { const r = e.getBoundingClientRect(); return r.width && r.height && !e.disabled && !e.readOnly; });
+ if (!fields.length) return null;
+ const e = fields[Math.floor(arguments[1] * fields.length)];
+ const proto = e instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
+ const value = arguments[2] ? e.value + arguments[0] : arguments[0];
+ e.focus();
+ Object.getOwnPropertyDescriptor(proto, "value").set.call(e, value);
+ e.dispatchEvent(new Event("input", { bubbles: true }));
+ return (e.getAttribute("aria-label") || e.placeholder || e.name || e.type).slice(0, 40)`,
+ text,
+ random(),
+ random() < 0.3,
+ );
+ return typed === null ? "type: no field" : `type into ${typed}: ${JSON.stringify(text.slice(0, 40))}`;
+ }
+ if (roll < 0.84) {
+ const key = pick(["Escape", "Enter", "Tab", "ArrowDown", "ArrowUp", "Backspace", " "]);
+ return s.exec(
+ `const e = document.activeElement || document.body;
+ const init = { key: arguments[0], bubbles: true, cancelable: true };
+ e.dispatchEvent(new KeyboardEvent("keydown", init));
+ e.dispatchEvent(new KeyboardEvent("keyup", init));
+ // Enter in a form submits it, as a browser would.
+ if (arguments[0] === "Enter" && e.form && e.tagName !== "TEXTAREA") e.form.requestSubmit();
+ return "key: " + arguments[0] + " on " + (e.getAttribute?.("aria-label") || e.tagName)`,
+ key,
+ );
+ }
+ if (roll < 0.9) {
+ return s.exec(
+ `const all = [...document.querySelectorAll("select")].filter((e) => e.getBoundingClientRect().width && !e.disabled && e.options.length);
+ if (!all.length) return "select: none";
+ const e = all[Math.floor(arguments[0] * all.length)];
+ const o = e.options[Math.floor(arguments[1] * e.options.length)];
+ Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, "value").set.call(e, o.value);
+ e.dispatchEvent(new Event("change", { bubbles: true }));
+ return "select: " + o.text.slice(0, 40)`,
+ random(),
+ random(),
+ );
+ }
+ if (roll < 0.95) {
+ // Typing into a session (never Enter: no command runs).
+ const text = gibberish().replace(/[\r\n]/g, "").slice(0, 80) || "\u0003";
+ const sent = await s.exec(
+ `const t = document.querySelector(".terminal:not([hidden]) .xterm-helper-textarea");
+ if (!t) return false;
+ t.focus(); t.dispatchEvent(new InputEvent("input", { data: arguments[0], inputType: "insertText", bubbles: true })); return true`,
+ text,
+ );
+ return sent ? `terminal: ${JSON.stringify(text.slice(0, 30))}` : "terminal: none open";
+ }
+ if (roll < 0.98) {
+ return s.exec(
+ `const items = [...document.querySelectorAll(".sidebar-nav button, .m-tabbar button, nav button")].filter((b) => b.getBoundingClientRect().width);
+ if (!items.length) return "nav: none";
+ const b = items[Math.floor(arguments[0] * items.length)]; b.click(); return "nav: " + b.innerText.trim().split("\\n")[0]`,
+ random(),
+ );
+ }
+ const [w, h] = pick([
+ [390, 844],
+ [640, 800],
+ [860, 700],
+ [1024, 700],
+ [1280, 800],
+ [1600, 900],
+ ]);
+ await s.resize(w, h);
+ return `resize: ${w}×${h}`;
+}
+
+async function fail(why) {
+ const report = [
+ `${label}: ${why}`,
+ `seed ${SEED}, step ${step} of ${STEPS} (MONKEY_SEED=${SEED} repeats it)`,
+ "last steps:",
+ ...trail.slice(-25).map((t) => ` ${t}`),
+ ].join("\n");
+ writeFileSync(`${out}/failure.txt`, `${report}\n`);
+ await s.screenshot(`${out}/failure.png`).catch(() => {});
+ throw new Error(report);
+}
+
+try {
+ await createVault(s, PASSWORD);
+ await s.resize(1280, 800);
+ const invoke = (cmd, args) => s.exec(`return window.__TAURI_INTERNALS__.invoke(arguments[0], arguments[1])`, cmd, args);
+ // Something on every page: a key, groups, hosts (where nothing listens), a snippet, a tunnel.
+ const key = await invoke("key_generate", { label: "monkey", vaultId: null });
+ const prod = (await invoke("group_save", { input: { name: "Production" } })).id;
+ await invoke("group_save", { input: { name: "Staging" } });
+ const auth = { kind: "key", keyId: key.id };
+ for (const [name, addr, groupId] of [
+ ["web-1", "127.0.0.9", prod],
+ ["web-2", "127.0.0.10", prod],
+ ["db-1", "127.0.0.11", null],
+ ]) {
+ await invoke("host_save", { input: { label: name, hostname: addr, port: 1, user: "deploy", auth, groupId, tags: ["monkey"] } });
+ }
+ await invoke("snippet_save", { input: { name: "Disk", body: "df -h", tags: ["ops"] } });
+ await invoke("plugin:event|emit", { event: "vault-changed", payload: null });
+ await sleep(500);
+ console.log(` ${label}: seed ${SEED}, ${STEPS} steps`);
+
+ for (step = 1; step <= STEPS; step++) {
+ await s.exec(PAGE_HOOKS);
+ let did;
+ try {
+ did = await act();
+ } catch (e) {
+ // The page went away under a script (a click that closed what it was in): note it, go on.
+ did = `(${String(e.message).slice(0, 80)})`;
+ }
+ trail.push(`${step}. ${did}`);
+ const kind = did.split(/[: ]/)[0];
+ counts[kind] = (counts[kind] ?? 0) + 1;
+ await sleep(between(40, 220));
+ let health;
+ try {
+ health = await s.exec(
+ `const errors = (window.__monkey || []).splice(0);
+ const root = document.getElementById("root");
+ return { errors, rendered: !!root && root.childElementCount > 0 }`,
+ );
+ } catch (e) {
+ await fail(`the app stopped answering: ${e.message}`);
+ }
+ if (health.errors.length) await fail(`the page threw: ${health.errors.join(" | ")}`);
+ if (!health.rendered) await fail("the page went blank (a render crashed)");
+ }
+ console.log(` ✓ ${label}: ${STEPS} random steps, nothing thrown (${Object.entries(counts).map(([k, v]) => `${k} ${v}`).join(", ")}), seed ${SEED}`);
+} finally {
+ await s.close();
+}
diff --git a/apps/desktop/e2e/run.sh b/apps/desktop/e2e/run.sh
index f30611f..1ed0f01 100755
--- a/apps/desktop/e2e/run.sh
+++ b/apps/desktop/e2e/run.sh
@@ -21,8 +21,8 @@ if [ -z "${XDG_RUNTIME_DIR:-}" ]; then
fi
# Free ports unless given: several runs can share a machine.
-free=$(node "$here/free-ports.mjs" 5)
-read -r free_feed free_ssh free_sync free_idp free_vallic <"$run/host-feed.log" 2>&1 &
+pids="$pids $!"
+for _ in $(seq 1 50); do
+ curl -s "http://127.0.0.1:$host_feed_port/" >/dev/null 2>&1 && break
+ sleep 0.2
+done
+
# A stand-in aws CLI (clouds/aws), whose running instance is the sshd above.
export MORPHGATE_CLOUD_CLI_DIR="$here/clouds" E2E_CLOUD_PORT="$ssh_port" E2E_CLOUD_USER="$(id -un)"
@@ -188,7 +200,7 @@ if want appimage; then
# launch (binfmt), the app's own relaunch after an update included. TMPDIR: where an AppImage
# unpacks to run, in the run's folder rather than /tmp, removed when the run ends.
mkdir -p "$run/appimage/tmp"
- APPIMAGE_EXTRACT_AND_RUN=1 APPIMAGELAUNCHER_DISABLE=1 TMPDIR="$run/appimage/tmp" MORPHGATE_BROWSER="$here/browser.sh" \
+ APPIMAGE_EXTRACT_AND_RUN=1 APPIMAGELAUNCHER_DISABLE=1 TMPDIR="$run/appimage/tmp" MORPHGATE_BROWSER="$here/browser.sh" E2E_BROWSER_LOG="$run/appimage/browser.log" \
dbus-run-session -- "$here/session.sh" "$run/appimage" \
node "$here/appimage.mjs" "$run/appimage/$appimage" "$run/appimage/shots" "$run/feed" \
"$run/sshd/client_key" "$ssh_port" "$(id -un)" "$version" "$next" 2>"$run/appimage/stderr.log"
@@ -242,4 +254,36 @@ if want mobile; then
"$run/mobile/key" "$ssh_port" "$(id -un)" 2>"$run/mobile/stderr.log"
fi
+# A monkey test of each package, only when asked for: E2E_ONLY=monkey (MONKEY_STEPS, MONKEY_SEED).
+if [ -n "${E2E_ONLY:-}" ] && want monkey; then
+ for pkg in appimage deb rpm; do
+ echo "e2e: monkey ($pkg)"
+ dir="$run/monkey-$pkg"
+ mkdir -p "$dir/root" "$dir/tmp"
+ case $pkg in
+ appimage) cp "$artifacts/$appimage" "$dir/" && bin="$dir/$appimage" ;;
+ deb) (cd "$dir/root" && ar x "$artifacts/$deb" && tar -xzf data.tar.gz) && bin="$dir/root/usr/bin/morphgate" ;;
+ rpm) (cd "$dir/root" && rpm2cpio "$artifacts/$rpm" | cpio -idm --quiet) && bin="$dir/root/usr/bin/morphgate" ;;
+ esac
+ APPIMAGE_EXTRACT_AND_RUN=1 APPIMAGELAUNCHER_DISABLE=1 TMPDIR="$dir/tmp" MORPHGATE_BROWSER="$here/browser.sh" \
+ dbus-run-session -- "$here/session.sh" "$dir" node "$here/monkey.mjs" "$bin" "$dir" "$pkg" 2>"$dir/stderr.log"
+ # A panic in the app's Rust side counts too.
+ if grep -h "panicked at" "$dir"/*.log; then echo "e2e: monkey ($pkg): the app panicked" >&2; exit 1; fi
+ rm -rf "$dir/tmp"
+ done
+fi
+
+# The README's demo (assets/demo/morphgate.svg), only when asked for: E2E_ONLY=demo.
+if [ -n "${E2E_ONLY:-}" ] && want demo; then
+ echo "e2e: demo (the AppImage's Hosts page and a session, drawn for the README)"
+ mkdir -p "$run/demo/tmp"
+ cp "$artifacts/$appimage" "$run/demo/"
+ APPIMAGE_EXTRACT_AND_RUN=1 APPIMAGELAUNCHER_DISABLE=1 TMPDIR="$run/demo/tmp" \
+ dbus-run-session -- "$here/session.sh" "$run/demo" \
+ node "$here/demo.mjs" "$run/demo/$appimage" "$run/demo" "$run/sshd/client_key" "$ssh_port" "$(id -un)" 2>"$run/demo/stderr.log"
+ [ -d "$root/tools/readme-demo/node_modules" ] || npm ci --prefix "$root/tools/readme-demo" --silent
+ node "$root/tools/readme-demo/demo-svg.mjs" "$run/demo/demo.json" "$run/demo/morphgate.svg"
+ echo "e2e: demo drawn: $run/demo/morphgate.svg (copy it to assets/demo/morphgate.svg)"
+fi
+
echo "e2e: all scenarios passed; screenshots and logs in $run"
diff --git a/apps/desktop/e2e/teams.mjs b/apps/desktop/e2e/teams.mjs
index 60cc04f..5a6bd58 100644
--- a/apps/desktop/e2e/teams.mjs
+++ b/apps/desktop/e2e/teams.mjs
@@ -110,7 +110,7 @@ async function openTeams(s) {
await s.click("button.sidebar-foot.teams");
// Already in an organization: back to the list.
await s.exec(
- `const b = [...document.querySelectorAll(".panel button.link")].find((b) => b.innerText.includes("All organizations"));
+ `const b = [...document.querySelectorAll(".panel button.link")].find((b) => b.innerText.trim() === "← Teams");
if (b) b.click(); return true`,
);
await s.waitText("Your fingerprint", ".panel");
@@ -118,13 +118,27 @@ async function openTeams(s) {
async function openOrg(s, name) {
await s.exec(
- `const li = [...document.querySelectorAll(".panel .device-list li")].find((l) => l.querySelector("strong")?.innerText === arguments[0]);
- li.querySelector("button").click(); return true`,
+ `const row = [...document.querySelectorAll(".panel [role=row]")].find((r) => r.querySelector(".lname")?.innerText === arguments[0]);
+ row.querySelector("button.lnamebtn").click(); return true`,
name,
);
await s.waitText("Members", ".panel");
}
+/** On a team's page: turns on the setting whose name has `name`, and saves. */
+async function turnOn(s, name) {
+ await s.pageTab("Settings");
+ const find = `[...document.querySelectorAll(".setting")].find((r) => r.querySelector(".setting-name")?.innerText.includes(arguments[0]))`;
+ await s.wait(() => s.exec(`return ${find} ? true : null`, name), `the setting ${name}`);
+ await s.exec(`${find}.querySelector("button[role=switch]").click(); return true`, name);
+ await s.clickButton("Save settings");
+ await s.wait(() => s.exec(`return [...document.querySelectorAll("button")].some((b) => b.innerText.includes("Save settings")) ? null : true`), "the settings to save");
+}
+
+/** A team page's row naming `name`, as text, or null. */
+const rowText = (s, name) =>
+ s.exec(`const r = [...document.querySelectorAll(".panel [role=row]")].find((r) => r.querySelector(".lname")?.innerText === arguments[0]); return r ? r.innerText : null`, name);
+
const alice = await session(app, process.env.WD_URL_ALICE);
const bob = await session(app, process.env.WD_URL_BOB);
// Bob's second device, for device approval.
@@ -138,20 +152,26 @@ try {
// Alice: an organization, a vault, and an invite for Bob with write access to it.
await openTeams(alice);
const aliceFp = await fingerprint(alice);
- await alice.type('input[aria-label="Organization name"]', "Acme Ops");
- await alice.clickButton("Create");
+ await alice.clickButton("New team");
+ await alice.type('input[aria-label="Team name"]', "Acme Ops");
+ await alice.clickButton("Create team");
+ await alice.wait(() => alice.exec(`return document.querySelector(".panel.page h1")?.innerText === "Acme Ops" || null`), "the team's page");
+ await alice.pageTab("Vaults");
await alice.waitText("No vaults yet", ".panel");
- await alice.type('input[aria-label="New vault name"]', "Production");
await alice.clickButton("New vault");
- await alice.wait(() => alice.hasText(".panel .device-list strong", "Production"), "the new vault");
- await alice.type('input[aria-label="Email to invite"]', bobEmail);
+ await alice.type('input[aria-label="Vault name"]', "Production");
+ await alice.clickButton("Create vault");
+ await alice.wait(() => alice.hasText(".panel .lname", "Production"), "the new vault");
+ await alice.clickButton("Invite people");
+ await alice.type('input[aria-label="Emails to invite"]', bobEmail);
await alice.click(".grants input[type=checkbox]");
- await alice.clickButton("Invite");
+ await alice.clickButton("Send invite");
const token = await alice.wait(
() => alice.exec(`const c = document.querySelector(".invite-code"); return c ? c.innerText.trim() : null`),
"the invite code",
);
await alice.screenshot(`${shots}/alice-invited.png`);
+ await alice.clickButton("Done");
ok("Alice created Acme Ops / Production and invited Bob");
// Bob: looks the invite up and joins only because the owner's fingerprint matches.
@@ -170,18 +190,34 @@ try {
ok("Bob saw Alice's fingerprint and joined");
// Alice: confirms Bob only because his fingerprint matches what he reads out.
- await alice.clickButton("All organizations");
+ await openTeams(alice);
await openOrg(alice, "Acme Ops");
- await alice.waitText("Waiting for confirmation", ".panel");
+ await alice.waitText("To confirm", ".panel");
+ await alice.screenshot(`${shots}/alice-needs-you.png`);
await alice.clickButton("Confirm…");
- assert.equal(await fingerprint(alice, "li.nested .fingerprint"), bobFp);
- await alice.click("li.nested label.check input");
+ assert.equal(await fingerprint(alice, ".lexpand .fingerprint"), bobFp);
+ await alice.click(".lexpand label.check input");
await alice.clickButton(`Confirm ${bobEmail}`);
await alice.wait(
- () => alice.exec(`return document.querySelector(".panel").innerText.includes("Waiting for confirmation") ? null : true`),
+ () => alice.exec(`return document.querySelector(".panel").innerText.includes("To confirm") ? null : true`),
"Bob's confirmation",
);
ok("Alice compared Bob's fingerprint and confirmed him");
+ // The owner's role menu on Bob's row stays inside its column, at every width.
+ for (const w of [860, 1024, 1280, 1600]) {
+ await alice.resize(w, 800);
+ const spill = await alice.wait(
+ () =>
+ alice.exec(`const sel = document.querySelector('select[aria-label^="Role of"]');
+ if (!sel) return null;
+ const cell = sel.closest('[role="cell"]').getBoundingClientRect(), r = sel.getBoundingClientRect();
+ return String(Math.round(Math.max(r.right - cell.right, cell.left - r.left, 0)));`),
+ "Bob's role menu",
+ );
+ assert.equal(spill, "0", `the role menu spills ${spill}px out of its column at ${w}px`);
+ }
+ await alice.screenshot(`${shots}/alice-members.png`);
+ await alice.resize(1280, 800);
// Alice: a host in the shared vault.
await alice.click('button[title="New host"]');
@@ -251,7 +287,7 @@ try {
await syncNow(alice);
await syncUntil(bob, () => sidebarHas(bob, "lab1"), "lab1 on Bob's device");
await openPage(bob, "Keys");
- assert.ok(await bob.hasText(".panel strong", "lab key"), "the key came along");
+ assert.ok(await bob.hasText(".panel .lname", "lab key"), "the key came along");
ok("Alice moved lab1 and the key it uses into Production; Bob has both");
await alice.exec(`document.querySelector('.sidebar button[aria-label="Edit lab1"]').click(); return true`);
await moveTo(alice, "Personal (only you)");
@@ -270,6 +306,7 @@ try {
const bobKey = await bob.exec(`return document.querySelector(".panel textarea").value`);
await openTeams(bob);
await openOrg(bob, "Acme Ops");
+ await bob.pageTab("Your SSH keys");
await bob.wait(() => bob.exec(`return document.querySelector('select[aria-label="Key to publish"]') ? true : null`), "the publish form");
await bob.choose('select[aria-label="Key to publish"]', "bob laptop");
await bob.clickButton("Publish");
@@ -280,7 +317,7 @@ try {
await openTeams(alice);
await openOrg(alice, "Acme Ops");
try {
- await alice.waitText("1 SSH key published", ".panel", 3000);
+ await alice.wait(async () => ((await rowText(alice, bobEmail)) ?? "").includes("1 published") || null, "Bob's published key", 3000);
break;
} catch (e) {
if (i >= 15) throw e;
@@ -291,9 +328,10 @@ try {
// Alice revokes Bob: the vault leaves his device, and Alice rotates its key.
await openTeams(alice);
await openOrg(alice, "Acme Ops");
+ await alice.pageTab("Vaults");
await alice.clickButton("Access");
- await alice.wait(() => alice.exec(`return document.querySelector("li.nested .access-row button.ghost-danger") ? true : null`), "the access list");
- await alice.click("li.nested .access-row button.ghost-danger");
+ await alice.wait(() => alice.exec(`return document.querySelector(".lexpand .access-row button.ghost-danger") ? true : null`), "the access list");
+ await alice.click(".lexpand .access-row button.ghost-danger");
await alice.waitText("Rotate the key", ".panel");
await syncUntil(bob, () => bob.exec(`return document.querySelector(".sidebar .vault-tag") ? null : true`), "the vault to leave Bob's device");
assert.ok(!(await sidebarHas(bob, "db1-primary")));
@@ -313,26 +351,19 @@ try {
// Policies: members may create vaults, and Bob makes one he manages.
await openTeams(alice);
await openOrg(alice, "Acme Ops");
- await alice.waitText("Policies", ".panel");
- await alice.exec(
- `[...document.querySelectorAll(".panel label.check")].find((l) => l.innerText.includes("Members can create vaults")).querySelector("input").click(); return true`,
- );
- await alice.clickButton("Save policies");
- await alice.wait(
- () => alice.exec(`return [...document.querySelectorAll("button")].some((b) => b.innerText.includes("Save policies")) ? null : true`),
- "the saved policies",
- );
+ await turnOn(alice, "Members can create vaults");
await openTeams(bob);
await openOrg(bob, "Acme Ops");
- await bob.wait(() => bob.exec(`return document.querySelector('input[aria-label="New vault name"]') ? true : null`), "Bob's new-vault form");
- await bob.type('input[aria-label="New vault name"]', "Bob's tools");
+ await bob.wait(() => bob.exec(`return [...document.querySelectorAll(".page-actions button")].some((b) => b.innerText === "New vault" && !b.disabled) || null`), "Bob's New vault");
await bob.clickButton("New vault");
- await bob.wait(() => bob.hasText(".panel .device-list strong", "Bob's tools"), "Bob's vault");
- assert.ok(await bob.hasText(".panel .device-list .kind-badge", "Can manage"));
+ await bob.type('input[aria-label="Vault name"]', "Bob's tools");
+ await bob.clickButton("Create vault");
+ await bob.wait(() => bob.hasText(".panel .lname", "Bob's tools"), "Bob's vault");
+ assert.ok(((await rowText(bob, "Bob's tools")) ?? "").includes("Can manage"));
ok("Alice let members create vaults; Bob made one he manages");
// The activity log tells the story.
- await alice.clickButton("Show activity");
+ await alice.pageTab("Activity");
const log = await alice.waitText("rotated the key of Production", ".panel");
assert.ok(log.includes(`invited ${bobEmail}`), "the invite is in the log");
assert.ok(log.includes(`removed ${bobEmail}'s access to Production`), "the revocation is in the log");
@@ -351,9 +382,7 @@ try {
ok("Alice downloaded the activity log as CSV");
// Acme Ops asks members to publish their SSH keys: Alice hasn't, so Teams reminds her.
- await alice.exec(`[...document.querySelectorAll(".page-aside label.check")].find((l) => l.innerText.includes("publish the SSH keys")).querySelector("input").click(); return true`);
- await alice.clickButton("Save policies");
- await alice.wait(() => alice.exec(`return [...document.querySelectorAll(".page-aside button")].some((b) => b.innerText.includes("Save policies")) ? null : true`), "the saved policy");
+ await turnOn(alice, "Ask members to publish their SSH keys");
await syncUntil(
alice,
() => alice.exec(`const b = document.querySelector("button.sidebar-foot.teams"); return b && b.innerText.includes("publish your keys") ? true : null`),
@@ -367,13 +396,7 @@ try {
// Device approval: Alice requires it. Bob's current device stays approved; his new one
// (Carol's app, signed in to Bob's account) waits, Alice denies it, and Bob approves it from
// his first device.
- await alice.wait(
- () => alice.exec(`return [...document.querySelectorAll(".page-aside label.check")].some((l) => l.innerText.includes("approve new devices")) || null`),
- "the policies",
- );
- await alice.exec(`[...document.querySelectorAll(".page-aside label.check")].find((l) => l.innerText.includes("approve new devices")).querySelector("input").click(); return true`);
- await alice.clickButton("Save policies");
- await alice.wait(() => alice.exec(`return [...document.querySelectorAll(".page-aside button")].some((b) => b.innerText.includes("Save policies")) ? null : true`), "the policies to save");
+ await turnOn(alice, "Approve new devices");
// Bob's second device started out with a vault of its own, a host in it, and joins his account
// from Sync ("I have an account"): the host goes into his account, and Acme Ops' vaults wait for
@@ -411,7 +434,7 @@ try {
await openTeams(alice);
await openOrg(alice, "Acme Ops");
- await alice.waitText("Waiting for approval", ".panel.page");
+ await alice.waitText("waits for approval", ".needs");
await alice.screenshot(`${shots}/alice-device-request.png`);
await alice.exec(`[...document.querySelectorAll(".device-requests button")].find((b) => b.innerText === "Deny").click(); return true`);
await alice.waitText("Denied", ".device-requests");
@@ -421,6 +444,7 @@ try {
await openTeams(bob);
await openOrg(bob, "Acme Ops");
+ await bob.pageTab("Members");
await bob.waitText("Denied", ".device-requests");
await bob.exec(`[...document.querySelectorAll(".device-requests button")].find((b) => b.innerText === "Approve").click(); return true`);
await bob.wait(() => bob.exec(`return document.querySelector(".device-requests") ? null : true`), "the request to go");
@@ -428,7 +452,7 @@ try {
await syncUntil(carol, () => carol.exec(`return document.querySelector("button.sidebar-foot.approval") ? null : true`), "the approval");
await openTeams(carol);
await openOrg(carol, "Acme Ops");
- await carol.wait(() => carol.hasText(".panel .device-list strong", "Bob's tools"), "Bob's vault on his new device");
+ await carol.wait(() => carol.hasText(".panel .lname", "Bob's tools"), "Bob's vault on his new device");
await carol.screenshot(`${shots}/carol-approved.png`);
ok("Acme Ops required device approval: Bob's new device waited, Alice denied it, Bob approved it from his first device");
@@ -438,7 +462,8 @@ try {
await syncNow(alice).catch(() => {});
await openTeams(alice);
await openOrg(alice, "Acme Ops");
- if (await alice.hasText(".panel .device-list strong", "Bob's tools")) break;
+ await alice.pageTab("Vaults");
+ if (await alice.hasText(".panel .lname", "Bob's tools")) break;
if (i >= 15) throw new Error("Bob's vault never reached Alice");
await new Promise((r) => setTimeout(r, 1000));
}
@@ -557,7 +582,7 @@ try {
// The session: box's row is live in the sidebar; Files in the route bar opens SFTP on it.
await alice.wait(
- () => alice.exec(`return document.querySelector(".sidebar .list li.selected .avatar .live.connected") ? true : null`),
+ () => alice.exec(`return document.querySelector(".sidebar .list li.selected .sq.connected") ? true : null`),
"box's live square",
);
await alice.wait(() => alice.exec(`return document.querySelector(".route-bar .pinned-key") ? true : null`), "the pinned key");
@@ -574,6 +599,7 @@ try {
// Shared credentials and a group: box signs in as the group's "lab" identity and keeps its
// session in tmux on the server; Reconnect picks that session back up.
await openPage(alice, "Keys");
+ await alice.pageTab("Identities");
await alice.clickButton("New identity");
await alice.waitText("New identity", ".panel");
await pickIn(alice, "Vault", "Bob's tools");
@@ -583,7 +609,8 @@ try {
await alice.click(".panel form button[type=submit]");
await alice.waitText("Edit identity", ".panel");
await openPage(alice, "Keys");
- await alice.wait(() => alice.hasText(".panel strong", "lab"), "the lab identity");
+ await alice.pageTab("Identities");
+ await alice.wait(() => alice.hasText(".panel .lname", "lab"), "the lab identity");
await alice.click('button[aria-label="New group"]');
await alice.waitText("New group", ".panel");
await pickIn(alice, "Vault", "Bob's tools");
@@ -608,8 +635,8 @@ try {
// The group's page lists it, with the identity its hosts use.
await alice.exec(`document.querySelector('.sidebar button[aria-label="Open group Lab"]').click(); return true`);
await alice.wait(() => alice.exec(`return document.querySelector(".panel.page h1")?.innerText === "Lab" || null`), "the Lab group's page");
- assert.ok(await alice.hasText(".panel .page-main strong", "box"), "box is on the Lab page");
- assert.ok(await alice.hasText(".panel .page-aside strong", "lab"), "so is the lab identity");
+ assert.ok(await alice.hasText(".panel .page-main .lname", "box"), "box is on the Lab page");
+ assert.ok(await alice.hasText(".panel .use-card .use-name", "lab"), "so is the lab identity");
await alice.screenshot(`${shots}/alice-group-page.png`);
await alice.exec(
`[...document.querySelectorAll(".sidebar .host-group li")].find((l) => l.querySelector(".row-title")?.innerText === "box").querySelector(".row-main").click(); return true`,
@@ -711,7 +738,7 @@ try {
assert.match(notice, new RegExp(`signed out of ${bobEmail.replace(/[.+]/g, "\\$&")} from another device`));
assert.ok(await carol.exec(`return !!document.querySelector(".gate-choice")`), "back at the first screen");
await carol.screenshot(`${shots}/carol-signed-out.png`);
- await carol.exec(`[...document.querySelectorAll(".gate-choice button")].find((b) => b.innerText === "I have an account").click(); return true`);
+ await carol.exec(`[...document.querySelectorAll(".gate-choice button")].find((b) => b.innerText === "Sign in").click(); return true`);
await useOwnServer(carol);
await carol.typeLabelled("Email", bobEmail);
await carol.typeLabelled("Master password", PASSWORD);
diff --git a/apps/desktop/e2e/webdriver.mjs b/apps/desktop/e2e/webdriver.mjs
index a58f9a9..338cb33 100644
--- a/apps/desktop/e2e/webdriver.mjs
+++ b/apps/desktop/e2e/webdriver.mjs
@@ -64,6 +64,25 @@ export async function session(application, base = BASE) {
text,
),
+ /** "Add from…" on a list page, then one of its items ("Import…", "Clouds…"). */
+ addFrom: async (item) => {
+ await s.clickButton("Add from…");
+ await s.wait(() => s.exec(`return document.querySelector(".add-from .menu") ? true : null`), "the Add from menu");
+ await s.exec(
+ `const b = [...document.querySelectorAll(".add-from .menu button")].find((b) => b.innerText.startsWith(arguments[0]));
+ if (!b) throw new Error("no menu item " + arguments[0]); b.click(); return true`,
+ item,
+ );
+ },
+
+ /** One of a page's tabs ("Identities", "Host CAs"). */
+ pageTab: (label) =>
+ s.exec(
+ `const b = [...document.querySelectorAll(".page-tabs button")].find((b) => b.innerText.startsWith(arguments[0]));
+ if (!b) throw new Error("no tab " + arguments[0]); b.click(); return true`,
+ label,
+ ),
+
/** Sets an input's value the way React notices. */
type: (css, text) =>
s.exec(
@@ -137,9 +156,9 @@ export async function session(application, base = BASE) {
return s;
}
-/** First launch: creates a vault with `password` and waits for the workspace. A new vault is
- * offered sync in a dialog: "Not now", unless `keepOffer` (the caller turns sync on in it). */
-export async function createVault(s, password, shots, { keepOffer = false } = {}) {
+/** First launch: creates a vault with `password` and waits for the workspace. Sync isn't offered
+ * then (the first screen had New account and Sign in): see `reload`. */
+export async function createVault(s, password, shots) {
await s.waitText("Create vault", "body", 60000);
await s.typeLabelled("Master password", password);
await s.typeLabelled("Repeat password", password);
@@ -155,11 +174,24 @@ export async function createVault(s, password, shots, { keepOffer = false } = {}
await s.exec(`[...document.querySelectorAll("button.primary")].find((b) => !b.disabled).click(); return true`);
// The desktop layout, or the phone one.
await s.wait(() => s.exec(`return document.querySelector("button.brand, .m-home") ? true : null`), "the workspace", 30000);
- await s.wait(() => s.exec(`return document.querySelector("dialog.sync-offer[open]") ? true : null`), "the offer to turn sync on");
- if (shots) await s.screenshot(`${shots}/sync-offer.png`);
- if (keepOffer) return;
- await s.exec(`[...document.querySelectorAll("dialog.sync-offer button")].find((b) => b.innerText === "Not now").click(); return true`);
- await s.wait(() => s.exec(`return document.querySelector("dialog.sync-offer") ? null : true`), "the offer to close");
+}
+
+/** The page loaded afresh, as when the app starts with the vault unlocked; with `weekLater`, as a
+ * week after a vault was created without sync (whose offer then opens). */
+export async function reload(s, { weekLater = false } = {}) {
+ await s.exec(
+ `const at = "morphgate.sync-offer-at";
+ if (arguments[0] && localStorage.getItem(at)) localStorage.setItem(at, String(Date.now() - 1));
+ window.__before = true;
+ setTimeout(() => location.reload(), 0);
+ return true`,
+ weekLater,
+ );
+ await s.wait(
+ () => s.exec(`return !window.__before && document.querySelector("button.brand, .m-home") ? true : null`),
+ "the workspace, loaded again",
+ 30000,
+ );
}
/** Opens About and returns the "Version …" line. */
diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json
index 95dafc2..3506ff7 100644
--- a/apps/desktop/package-lock.json
+++ b/apps/desktop/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "morphgate-desktop",
- "version": "0.2.0",
+ "version": "0.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "morphgate-desktop",
- "version": "0.2.0",
+ "version": "0.2.1",
"license": "MPL-2.0",
"dependencies": {
"@tauri-apps/api": "2.12.4",
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index f097f73..333e97c 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -1,7 +1,7 @@
{
"name": "morphgate-desktop",
"private": true,
- "version": "0.2.0",
+ "version": "0.2.1",
"license": "MPL-2.0",
"type": "module",
"scripts": {
diff --git a/apps/desktop/scripts/licenses.mjs b/apps/desktop/scripts/licenses.mjs
index 5a0ab59..16c2c43 100644
--- a/apps/desktop/scripts/licenses.mjs
+++ b/apps/desktop/scripts/licenses.mjs
@@ -139,6 +139,22 @@ for (const [name, url] of [
components.push({ name, license: "OFL-1.1", kind: "font", url, texts: [ofl] });
}
+// Data: EFF's long wordlist, for suggested master passwords (src-tauri/wordlist/README.md).
+components.push({
+ name: "EFF's long wordlist",
+ license: "CC-BY-3.0-US",
+ kind: "data",
+ url: "https://www.eff.org/dice",
+ texts: [
+ textId(
+ "EFF's long wordlist for passphrases, by the Electronic Frontier Foundation (https://www.eff.org/dice), " +
+ "is licensed under the Creative Commons Attribution 3.0 United States License " +
+ "(https://creativecommons.org/licenses/by/3.0/us/). MorphGate uses its words as published, " +
+ "leaving out the four that contain a hyphen.\n",
+ ),
+ ],
+});
+
// Android: the Java libraries the app, Tauri and its plugins name in their Gradle files.
if (target.includes("android")) {
const apache = textId(STANDARD["Apache-2.0"]());
@@ -167,7 +183,7 @@ writeFileSync(out, json);
for (const w of warnings) console.warn(`licenses: ${w}`);
const count = (k) => components.filter((c) => c.kind === k).length;
console.log(
- `licenses: ${count("crate")} crates, ${count("npm")} npm packages, ${count("font")} fonts` +
+ `licenses: ${count("crate")} crates, ${count("npm")} npm packages, ${count("font")} fonts, ${count("data")} word list` +
(count("android") ? `, ${count("android")} Android libraries` : "") +
` for ${target}; ${texts.length} texts, ${Math.round(json.length / 1024)} KiB`,
);
diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml
index 2eceaa5..271a5a1 100644
--- a/apps/desktop/src-tauri/Cargo.toml
+++ b/apps/desktop/src-tauri/Cargo.toml
@@ -27,6 +27,7 @@ qrcode = { version = "0.14.1", default-features = false, features = ["svg"] }
serde.workspace = true
serde_json.workspace = true
sha2 = "0.11.0"
+getrandom = "0.4.3"
zxcvbn = "3.1.1"
tauri = { version = "2", features = [] }
tokio.workspace = true
diff --git a/apps/desktop/src-tauri/src/bulk.rs b/apps/desktop/src-tauri/src/bulk.rs
index 4c08234..076d214 100644
--- a/apps/desktop/src-tauri/src/bulk.rs
+++ b/apps/desktop/src-tauri/src/bulk.rs
@@ -1,7 +1,7 @@
//! Changes to several items at once, from the lists' selections: deleting them, and putting hosts
//! in a group. Each item is changed as on its own; the ones that can't be are said, not hidden.
-use mg_store::{ItemData, LocalVault};
+use mg_store::{Item, ItemData, LocalVault, Snippet};
use serde::Serialize;
use tauri::State;
@@ -36,6 +36,18 @@ pub(crate) fn delete_items(v: &mut LocalVault, ids: &[String]) -> BulkResult {
let mut left: Vec = Vec::new();
for id in ids {
match v.item(id) {
+ // A feed's snippets go when the feed drops them.
+ Ok(Item {
+ data:
+ ItemData::Snippet(Snippet {
+ source: Some(_),
+ name,
+ ..
+ }),
+ ..
+ }) => result.refused.push(format!(
+ "\"{name}\" comes from a feed: it goes when the feed drops it"
+ )),
Ok(item) if plain(&item.data) => left.push(id.clone()),
Ok(_) => result
.refused
@@ -209,6 +221,7 @@ mod tests {
name: "df".into(),
body: "df -h".into(),
tags: vec![],
+ source: None,
}),
)
.unwrap()
diff --git a/apps/desktop/src-tauri/src/cloud.rs b/apps/desktop/src-tauri/src/cloud.rs
index 7a7fa38..05dbc22 100644
--- a/apps/desktop/src-tauri/src/cloud.rs
+++ b/apps/desktop/src-tauri/src/cloud.rs
@@ -1,21 +1,24 @@
//! Cloud sources (docs/features/cloud-sources.md): hosts kept in step with Vallic Cloud, AWS,
-//! Google Cloud, Azure, Hetzner Cloud and DigitalOcean. For Vallic each device signs in itself (the
-//! console's device flow) and keeps its sign-in on the device; the others are listed with the
-//! provider's CLI as this device has it set up. The vault syncs the source and the hosts it made. A
-//! refresh brings the provider's hosts in: what the provider decides (address, port, user, login
-//! and jump commands, the label until it's renamed here) follows it, what's the person's (key,
-//! scheme, tags, notes, group) stays; a host the provider no longer lists is marked gone, not
-//! deleted.
-
-use std::collections::HashMap;
+//! Google Cloud, Azure, Hetzner Cloud and DigitalOcean, or a feed. For Vallic each device signs in
+//! itself (the console's device flow) and keeps its sign-in on the device; the others are listed
+//! with the provider's CLI as this device has it set up; a feed is read from its URL with the
+//! token the source holds. The vault syncs the source and the hosts it made. A refresh brings the
+//! provider's hosts in: what the provider decides (address, port, user, login and jump commands,
+//! the label until it's renamed here) follows it, what's the person's (key, scheme, tags, notes,
+//! group) stays; a host the provider no longer lists is marked gone, not deleted. A feed's
+//! snippets follow it whole.
+
+use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use mg_inventory::cli::Cli;
+use mg_inventory::feed::{self, FeedSnippet};
use mg_inventory::shape::Shape;
use mg_inventory::vallic::{DeviceAuth, Poll, Session, Vallic};
use mg_inventory::{InventoryHost, aws, azure, gcp};
use mg_store::{
- CloudReach, CloudSource, Host, HostAuth, HostGroup, HostSource, ItemData, LocalVault,
+ Access, CloudReach, CloudSource, Host, HostAuth, HostGroup, HostSource, Item, ItemData,
+ LocalVault, Snippet, SnippetSource,
};
use serde::{Deserialize, Serialize};
use tauri::State;
@@ -23,9 +26,32 @@ use tauri::State;
use crate::vault::{VaultState, save};
/// One cloud operation at a time on this device: a refresh token is used once, and two refreshes
-/// at once would replay it, which ends the sign-in.
+/// at once would replay it, which ends the sign-in. And why the last refresh of a source failed
+/// here, for its card (an automatic one has nowhere else to say it).
#[derive(Default)]
-pub struct CloudState(tokio::sync::Mutex<()>);
+pub struct CloudState {
+ one: tokio::sync::Mutex<()>,
+ failed: std::sync::Mutex>,
+}
+
+impl CloudState {
+ fn note(&self, source_id: &str, result: &Result) {
+ if let Ok(mut failed) = self.failed.lock() {
+ match result {
+ Ok(_) => failed.remove(source_id),
+ Err(e) => failed.insert(source_id.to_owned(), e.clone()),
+ };
+ }
+ }
+
+ fn failure(&self, source_id: &str) -> Option {
+ self.failed.lock().ok()?.get(source_id).cloned()
+ }
+}
+
+/// A feed's source refreshes itself on desktops: at unlock, then every 15 minutes while unlocked
+/// (the app's timer), unless it was refreshed (on any device) in the last 10.
+const FEED_FRESH_SECS: i64 = 10 * 60;
/// Where this device keeps its sign-in to a source.
fn secret_name(source_id: &str) -> String {
@@ -99,9 +125,27 @@ pub struct SourceView {
group_tag: Option,
/// Where this device runs the provider's CLI from, when it was given.
cli_path: Option,
+ /// A feed's address.
+ url: Option,
+ /// The end of a feed's token, to tell tokens apart (`…f3a9`); the rest is never shown.
+ token_end: Option,
+ /// Snippets a feed keeps.
+ snippets: usize,
+ /// Why the last refresh on this device failed (an automatic one included).
+ failed: Option,
}
-fn views(v: &LocalVault) -> Result, String> {
+/// The token as the source shows it: its last 4 characters where it's long enough to spare them.
+fn token_end(token: &str) -> String {
+ let chars: Vec = token.trim().chars().collect();
+ if chars.len() >= 16 {
+ format!("…{}", chars[chars.len() - 4..].iter().collect::())
+ } else {
+ "…".into()
+ }
+}
+
+fn views(v: &LocalVault, cloud: &CloudState) -> Result, String> {
let items = v.items().map_err(|e| e.to_string())?;
let mut out = Vec::new();
for item in &items {
@@ -113,6 +157,12 @@ fn views(v: &LocalVault) -> Result, String> {
_ => None,
});
let (hosts, gone) = kept.fold((0, 0), |(n, g), s| (n + 1, g + usize::from(s.gone)));
+ let snippets = items
+ .iter()
+ .filter(|i| {
+ matches!(&i.data, ItemData::Snippet(Snippet { source: Some(s), .. }) if s.source_ref == item.id)
+ })
+ .count();
out.push(SourceView {
id: item.id.clone(),
vault_id: item.vault_id.clone(),
@@ -136,6 +186,10 @@ fn views(v: &LocalVault) -> Result, String> {
user: c.user.clone(),
group_tag: c.group_tag.clone(),
cli_path: v.setting(&cli_setting(&c.provider)).ok().flatten(),
+ url: c.url.clone(),
+ token_end: c.token.as_deref().map(token_end),
+ snippets,
+ failed: cloud.failure(&item.id),
});
}
out.sort_by(|a, b| a.label.cmp(&b.label));
@@ -143,8 +197,11 @@ fn views(v: &LocalVault) -> Result, String> {
}
#[tauri::command]
-pub fn cloud_sources(vault: State<'_, VaultState>) -> Result, String> {
- views(&*vault.guard()?)
+pub fn cloud_sources(
+ vault: State<'_, VaultState>,
+ cloud: State<'_, CloudState>,
+) -> Result, String> {
+ views(&*vault.guard()?, &cloud)
}
/// Starts signing in to Vallic Cloud: the code to show and the page to type it at.
@@ -183,7 +240,7 @@ pub async fn vallic_sign_in_poll(
source_id: Option,
vault_id: Option,
) -> Result {
- let _one = cloud.0.lock().await;
+ let _one = cloud.one.lock().await;
let v = client(endpoint.as_deref())?;
let tokens = match v.poll(&device_code).await.map_err(|e| e.to_string())? {
Poll::Pending => return Ok(SignInStatus::Pending { slower: false }),
@@ -229,7 +286,7 @@ pub async fn vallic_sign_in_poll(
};
vault.changed();
let refresh = refresh_with(&vault, &v, &id, &mut session).await?;
- let source = views(&*vault.guard()?)?
+ let source = views(&*vault.guard()?, &cloud)?
.into_iter()
.find(|s| s.id == id)
.ok_or("the source went away")?;
@@ -259,8 +316,13 @@ pub struct RefreshResult {
without_shell: Vec,
/// Machines without the address the source uses (running ones, or new stopped ones).
without_address: Vec,
- /// Regions, projects or subscriptions that couldn't be listed, and why.
+ /// Regions, projects or subscriptions that couldn't be listed, and why; a feed's entries
+ /// left out.
skipped: Vec,
+ /// A feed's snippets.
+ snippets_added: usize,
+ snippets_updated: usize,
+ snippets_removed: usize,
}
/// Brings a source's hosts in from the provider: with this device's sign-in (Vallic Cloud), or
@@ -271,33 +333,136 @@ pub async fn cloud_refresh(
cloud: State<'_, CloudState>,
source_id: String,
) -> Result {
- let _one = cloud.0.lock().await;
+ let _one = cloud.one.lock().await;
+ let result = refresh(&vault, &source_id).await;
+ cloud.note(&source_id, &result);
+ result
+}
+
+/// [`cloud_refresh`], with the one cloud operation running here held.
+async fn refresh(vault: &VaultState, source_id: &str) -> Result {
let provider = match vault
.guard()?
- .item(&source_id)
+ .item(source_id)
.map_err(|e| e.to_string())?
.data
{
ItemData::CloudSource(c) => c.provider,
_ => return Err("not a cloud source".into()),
};
- if provider != "vallic" {
- return refresh_cli(&vault, &source_id).await;
+ match provider.as_str() {
+ "vallic" => {}
+ "feed" => return refresh_feed(vault, source_id).await,
+ _ => return refresh_cli(vault, source_id).await,
}
let (endpoint, refresh) = {
let v = vault.guard()?;
- let ItemData::CloudSource(c) = v.item(&source_id).map_err(|e| e.to_string())?.data else {
+ let ItemData::CloudSource(c) = v.item(source_id).map_err(|e| e.to_string())?.data else {
return Err("not a cloud source".into());
};
let refresh = v
- .local_secret(&secret_name(&source_id))
+ .local_secret(&secret_name(source_id))
.map_err(|e| e.to_string())?
.ok_or("this device isn't signed in to it: sign in first")?;
(c.endpoint, refresh.to_string())
};
let v = client(endpoint.as_deref())?;
let mut session = Session::new(refresh);
- refresh_with(&vault, &v, &source_id, &mut session).await
+ refresh_with(vault, &v, source_id, &mut session).await
+}
+
+/// A feed: read from its URL with the source's token; its hosts and snippets into the vault.
+async fn refresh_feed(vault: &VaultState, source_id: &str) -> Result {
+ let c = match vault
+ .guard()?
+ .item(source_id)
+ .map_err(|e| e.to_string())?
+ .data
+ {
+ ItemData::CloudSource(c) => c,
+ _ => return Err("not a cloud source".into()),
+ };
+ let (Some(url), Some(token)) = (&c.url, &c.token) else {
+ return Err(format!("{}: a feed needs its address and token", c.label));
+ };
+ let feed = feed::fetch(&c.label, url, token)
+ .await
+ .map_err(|e| match e {
+ mg_inventory::Error::TokenRefused(name) => {
+ format!("{name} refused the token: change it in the source's settings")
+ }
+ e => e.to_string(),
+ })?;
+ apply_feed(vault, source_id, feed)
+}
+
+/// What a feed listed into the vault of source `source_id`: its hosts, then its snippets.
+fn apply_feed(
+ vault: &VaultState,
+ source_id: &str,
+ feed: feed::Feed,
+) -> Result {
+ let result = {
+ let mut store = vault.guard()?;
+ let mut result = apply(&mut store, source_id, &feed.hosts)?;
+ let (added, updated, removed) = apply_snippets(&mut store, source_id, &feed.snippets)?;
+ result.snippets_added = added;
+ result.snippets_updated = updated;
+ result.snippets_removed = removed;
+ result.skipped = feed.skipped;
+ result
+ };
+ vault.changed();
+ Ok(result)
+}
+
+/// Refreshes the feeds that are due: on a vault this device can write to, and not refreshed (on
+/// any device) in the last 10 minutes. The app calls it at unlock and every 15 minutes; a failure
+/// shows on the source's card. Returns how many it refreshed.
+#[tauri::command]
+pub async fn cloud_auto_refresh(
+ vault: State<'_, VaultState>,
+ cloud: State<'_, CloudState>,
+) -> Result {
+ if cfg!(mobile) {
+ return Ok(0);
+ }
+ let due = |v: &LocalVault| -> Result, String> {
+ let writable: HashSet = v
+ .vaults()
+ .map_err(|e| e.to_string())?
+ .into_iter()
+ .filter(|x| x.access >= Access::Write)
+ .map(|x| x.id)
+ .collect();
+ Ok(v.items()
+ .map_err(|e| e.to_string())?
+ .into_iter()
+ .filter(|i| writable.contains(&i.vault_id))
+ .filter_map(|i| match i.data {
+ ItemData::CloudSource(c)
+ if c.provider == "feed" && now() - c.refreshed_at >= FEED_FRESH_SECS =>
+ {
+ Some(i.id)
+ }
+ _ => None,
+ })
+ .collect())
+ };
+ let mut refreshed = 0;
+ let ids = due(&*vault.guard()?)?;
+ for id in ids {
+ let _one = cloud.one.lock().await;
+ // Refreshed meanwhile (by hand, or a sync brought another device's refresh).
+ let still = due(&*vault.guard()?)?.contains(&id);
+ if !still {
+ continue;
+ }
+ let result = refresh_feed(&vault, &id).await;
+ cloud.note(&id, &result);
+ refreshed += usize::from(result.is_ok());
+ }
+ Ok(refreshed)
}
async fn refresh_with(
@@ -410,6 +575,43 @@ async fn refresh_cli(vault: &VaultState, source_id: &str) -> Result mg_store::Result- {
+ let id = mg_store::derived_id(&[vault_id, source_id, kind, key]);
+ match v.item(&id) {
+ Ok(_) => save(v, None, Some(vault_id), data),
+ Err(mg_store::Error::NotFound(_)) => v.insert_in(vault_id, &id, data),
+ Err(e) => Err(e),
+ }
+}
+
+/// The host's tags: the person's (what the source didn't give it), then what the source gives
+/// it now.
+fn merged_tags(h: &Host, before: &[String], now: &[String]) -> Vec
{
+ let mut tags: Vec = h
+ .tags
+ .iter()
+ .filter(|t| !before.contains(t))
+ .cloned()
+ .collect();
+ for t in now {
+ if !tags.contains(t) {
+ tags.push(t.clone());
+ }
+ }
+ tags
+}
+
/// The platform's hosts into the vault of source `source_id`.
fn apply(
v: &mut LocalVault,
@@ -477,6 +679,8 @@ fn apply(
place: via.place.clone(),
project: via.project.clone(),
}),
+ host_keys: ih.host_keys.clone(),
+ tags: ih.tags.clone(),
};
match kept.remove(&ih.external_id) {
Some((id, mut h)) => {
@@ -496,6 +700,12 @@ fn apply(
if ih.jump.is_none() {
h.jump_ref = source_jump.clone().filter(|_| ih.via.is_none());
}
+ let before = h
+ .source
+ .as_ref()
+ .map(|s| s.tags.clone())
+ .unwrap_or_default();
+ h.tags = merged_tags(&h, &before, &ih.tags);
h.source = Some(Box::new(mark(&ih.label)));
if h != was {
save(v, Some(&id), None, ItemData::Host(h)).map_err(e)?;
@@ -506,18 +716,28 @@ fn apply(
// New and without an address (stopped): nothing to connect to yet.
None if ih.hostname.is_empty() => result.without_address.push(ih.label.clone()),
None => {
+ // In the group of that name, made when missing; none where the source names none.
let group_ref = match groups.get(&ih.group.to_lowercase()) {
- Some(g) => g.clone(),
+ _ if ih.group.trim().is_empty() => None,
+ Some(g) => Some(g.clone()),
None => {
let group = HostGroup {
name: ih.group.clone(),
..HostGroup::default()
};
- let id = save(v, None, Some(&vault_id), ItemData::HostGroup(group))
- .map_err(e)?
- .id;
- groups.insert(ih.group.to_lowercase(), id.clone());
- id
+ let key = ih.group.to_lowercase();
+ let id = create(
+ v,
+ &vault_id,
+ source_id,
+ "group",
+ &key,
+ ItemData::HostGroup(group),
+ )
+ .map_err(e)?
+ .id;
+ groups.insert(key, id.clone());
+ Some(id)
}
};
let host = Host {
@@ -535,9 +755,9 @@ fn apply(
vpn_ref: None,
theme_ref: None,
record_sessions: false,
- tags: Vec::new(),
+ tags: ih.tags.clone(),
notes: String::new(),
- group_ref: Some(group_ref),
+ group_ref,
identity_ref: None,
keep_session: false,
forward_agent: false,
@@ -546,9 +766,16 @@ fn apply(
proxy_command: None,
source: Some(Box::new(mark(&ih.label))),
};
- let id = save(v, None, Some(&vault_id), ItemData::Host(host))
- .map_err(e)?
- .id;
+ let id = create(
+ v,
+ &vault_id,
+ source_id,
+ "host",
+ &ih.external_id,
+ ItemData::Host(host),
+ )
+ .map_err(e)?
+ .id;
ids.insert(ih.external_id.clone(), id);
result.added += 1;
}
@@ -583,6 +810,68 @@ fn apply(
Ok(result)
}
+/// A feed's snippets into its vault: the feed decides them whole (they're read-only in the app),
+/// so one it no longer lists is deleted. Returns (added, updated, removed).
+fn apply_snippets(
+ v: &mut LocalVault,
+ source_id: &str,
+ snippets: &[FeedSnippet],
+) -> Result<(usize, usize, usize), String> {
+ let e = |e: mg_store::Error| e.to_string();
+ let vault_id = v.item(source_id).map_err(e)?.vault_id;
+ let mut kept: HashMap = v
+ .items()
+ .map_err(e)?
+ .into_iter()
+ .filter(|i| i.vault_id == vault_id)
+ .filter_map(|i| match i.data {
+ ItemData::Snippet(s) => match &s.source {
+ Some(src) if src.source_ref == source_id => {
+ Some((src.external_id.clone(), (i.id, s)))
+ }
+ _ => None,
+ },
+ _ => None,
+ })
+ .collect();
+ let (mut added, mut updated) = (0, 0);
+ for fs in snippets {
+ let snippet = Snippet {
+ name: fs.name.clone(),
+ body: fs.body.clone(),
+ tags: fs.tags.clone(),
+ source: Some(SnippetSource {
+ source_ref: source_id.to_owned(),
+ external_id: fs.external_id.clone(),
+ }),
+ };
+ match kept.remove(&fs.external_id) {
+ Some((_, was)) if was == snippet => {}
+ Some((id, _)) => {
+ save(v, Some(&id), None, ItemData::Snippet(snippet)).map_err(e)?;
+ updated += 1;
+ }
+ None => {
+ create(
+ v,
+ &vault_id,
+ source_id,
+ "snippet",
+ &fs.external_id,
+ ItemData::Snippet(snippet),
+ )
+ .map_err(e)?;
+ added += 1;
+ }
+ }
+ }
+ let removed = kept.len();
+ for (id, _) in kept.into_values() {
+ v.delete(&id).map_err(e)?;
+ }
+ Ok((added, updated, removed))
+}
+
/// Ends this device's sign-in (on the console too); the source and its hosts stay.
#[tauri::command]
pub async fn cloud_sign_out(
@@ -590,7 +879,7 @@ pub async fn cloud_sign_out(
cloud: State<'_, CloudState>,
source_id: String,
) -> Result<(), String> {
- let _one = cloud.0.lock().await;
+ let _one = cloud.one.lock().await;
let (endpoint, refresh) = {
let v = vault.guard()?;
let endpoint = match v.item(&source_id).map_err(|e| e.to_string())?.data {
@@ -610,7 +899,8 @@ pub async fn cloud_sign_out(
.map_err(|e| e.to_string())
}
-/// Removes a source: its hosts too (`delete_hosts`), or they stay as hosts of their own.
+/// Removes a source: its hosts (and a feed's snippets) too (`delete_hosts`), or they stay as
+/// hosts and snippets of their own.
#[tauri::command]
pub async fn cloud_source_delete(
vault: State<'_, VaultState>,
@@ -637,6 +927,19 @@ pub async fn cloud_source_delete(
_ => None,
})
.collect();
+ let snippets: Vec<(String, Snippet)> = v
+ .items()
+ .map_err(e)?
+ .into_iter()
+ .filter_map(|i| match i.data {
+ ItemData::Snippet(s)
+ if s.source.as_ref().is_some_and(|s| s.source_ref == source_id) =>
+ {
+ Some((i.id, s))
+ }
+ _ => None,
+ })
+ .collect();
if delete_hosts {
// Hosts that jump through others first, so none is still in use when it goes.
let (through, rest): (Vec<_>, Vec<_>) =
@@ -644,11 +947,18 @@ pub async fn cloud_source_delete(
for (id, _) in through.into_iter().chain(rest) {
v.delete(&id).map_err(e)?;
}
+ for (id, _) in snippets {
+ v.delete(&id).map_err(e)?;
+ }
} else {
for (id, mut h) in hosts {
h.source = None;
save(&mut v, Some(&id), None, ItemData::Host(h)).map_err(e)?;
}
+ for (id, mut s) in snippets {
+ s.source = None;
+ save(&mut v, Some(&id), None, ItemData::Snippet(s)).map_err(e)?;
+ }
}
v.delete(&source_id).map_err(e)?;
}
@@ -888,7 +1198,7 @@ pub async fn cloud_source_add(
key_id: Option,
settings: SourceSettings,
) -> Result {
- let _one = cloud.0.lock().await;
+ let _one = cloud.one.lock().await;
let cli = find_cli(&*vault.guard()?, &provider)?;
let profile = trimmed(settings.profile.clone());
let account = whoami(&cli, &provider, profile.as_deref()).await?;
@@ -925,7 +1235,7 @@ pub async fn cloud_source_add(
};
vault.changed();
let refresh = refresh_cli(&vault, &id).await?;
- let source = views(&*vault.guard()?)?
+ let source = views(&*vault.guard()?, &cloud)?
.into_iter()
.find(|s| s.id == id)
.ok_or("the source went away")?;
@@ -948,8 +1258,10 @@ pub fn cloud_source_settings(
let ItemData::CloudSource(mut source) = v.item(&source_id).map_err(e)?.data else {
return Err("not a cloud source".into());
};
- if source.provider == "vallic" {
- return Err("Vallic Cloud lists what your sign-in reaches".into());
+ match source.provider.as_str() {
+ "vallic" => return Err("Vallic Cloud lists what your sign-in reaches".into()),
+ "feed" => return Err("a feed's settings are its address and token".into()),
+ _ => {}
}
settings.onto(&mut source);
save(
@@ -964,6 +1276,118 @@ pub fn cloud_source_settings(
Ok(())
}
+/// A feed source's name, address and token (`None` or empty: the token stays as it is).
+#[derive(Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct FeedInput {
+ #[serde(default)]
+ label: Option,
+ url: String,
+ #[serde(default)]
+ token: Option,
+}
+
+/// A feed's address, checked: `https://`, or `http://` on this computer.
+fn feed_url(url: &str) -> Result {
+ feed::check_url(url)
+ .map(|u| u.to_string())
+ .map_err(|e| e.to_string())
+}
+
+/// Adds a feed (in `vault_id`, default the personal vault) once it has been read with the token,
+/// named as given, else as the feed calls itself, else by its host; then brings its hosts and
+/// snippets in.
+#[tauri::command]
+pub async fn cloud_feed_add(
+ vault: State<'_, VaultState>,
+ cloud: State<'_, CloudState>,
+ vault_id: Option,
+ key_id: Option,
+ input: FeedInput,
+) -> Result {
+ let _one = cloud.one.lock().await;
+ let parsed = feed::check_url(&input.url).map_err(|e| e.to_string())?;
+ let url = parsed.to_string();
+ let host = parsed.host_str().unwrap_or("the feed").to_owned();
+ let token = trimmed(input.token).ok_or("a feed needs the token it's read with")?;
+ let label = trimmed(input.label);
+ // Read before it's kept: a wrong address or token is said now, and the feed may name itself.
+ let read = feed::fetch(label.as_deref().unwrap_or(&host), &url, &token)
+ .await
+ .map_err(|e| e.to_string())?;
+ let id = {
+ let mut store = vault.guard()?;
+ let target = match vault_id.filter(|v| !v.is_empty()) {
+ Some(id) => id,
+ None => personal(&store)?,
+ };
+ let source = CloudSource {
+ label: label.or(read.name.clone()).unwrap_or(host),
+ provider: "feed".into(),
+ key_ref: trimmed(key_id),
+ account: String::new(),
+ url: Some(url),
+ token: Some(token),
+ ..CloudSource::default()
+ };
+ save(
+ &mut store,
+ None,
+ Some(&target),
+ ItemData::CloudSource(source),
+ )
+ .map_err(|e| e.to_string())?
+ .id
+ };
+ vault.changed();
+ // What was read, brought in: no second request.
+ let refresh = apply_feed(&vault, &id, read);
+ cloud.note(&id, &refresh);
+ let source = views(&*vault.guard()?, &cloud)?
+ .into_iter()
+ .find(|s| s.id == id)
+ .ok_or("the source went away")?;
+ Ok(Added {
+ source: Box::new(source),
+ refresh: refresh?,
+ })
+}
+
+/// Changes a feed's name, address or token (the next refresh reads it).
+#[tauri::command]
+pub fn cloud_feed_settings(
+ vault: State<'_, VaultState>,
+ source_id: String,
+ input: FeedInput,
+) -> Result<(), String> {
+ {
+ let mut v = vault.guard()?;
+ let e = |e: mg_store::Error| e.to_string();
+ let ItemData::CloudSource(mut source) = v.item(&source_id).map_err(e)?.data else {
+ return Err("not a cloud source".into());
+ };
+ if source.provider != "feed" {
+ return Err("not a feed".into());
+ }
+ if let Some(label) = trimmed(input.label) {
+ source.label = label;
+ }
+ source.url = Some(feed_url(&input.url)?);
+ if let Some(token) = trimmed(input.token) {
+ source.token = Some(token);
+ }
+ save(
+ &mut v,
+ Some(&source_id),
+ None,
+ ItemData::CloudSource(source),
+ )
+ .map_err(e)?;
+ }
+ vault.changed();
+ Ok(())
+}
+
/// Puts a key's public half on the signed-in person's Vallic account.
#[tauri::command]
pub async fn cloud_add_key(
@@ -972,7 +1396,7 @@ pub async fn cloud_add_key(
source_id: String,
key_id: String,
) -> Result<(), String> {
- let _one = cloud.0.lock().await;
+ let _one = cloud.one.lock().await;
let (endpoint, refresh, label, public_key) = {
let v = vault.guard()?;
let e = |e: mg_store::Error| e.to_string();
@@ -1037,6 +1461,8 @@ mod tests {
state: None,
key_name: None,
via: None,
+ host_keys: vec![],
+ tags: vec![],
}
}
@@ -1109,6 +1535,8 @@ mod tests {
state: state.map(str::to_owned),
key_name: Some("deploy".into()),
via: None,
+ host_keys: vec![],
+ tags: vec![],
};
let first = apply(
&mut v,
@@ -1178,6 +1606,8 @@ mod tests {
place: "eu-central-1".into(),
project: None,
}),
+ host_keys: vec![],
+ tags: vec![],
};
let command = reach_command(&v, &source).unwrap().unwrap();
assert_eq!(
@@ -1292,4 +1722,115 @@ mod tests {
};
assert!(gone.source.unwrap().gone);
}
+
+ /// A feed: its hosts under ids worked out from what they are (the same on every device),
+ /// its groups, tags (the person's kept) and host keys; its snippets follow it whole.
+ #[test]
+ fn a_feed_keeps_hosts_and_snippets_in_step() {
+ let mut v = vault();
+ let target = personal(&v).unwrap();
+ let sid = save(
+ &mut v,
+ None,
+ Some(&target),
+ ItemData::CloudSource(CloudSource {
+ label: "Ops".into(),
+ provider: "feed".into(),
+ url: Some("https://ops.example/feed".into()),
+ token: Some("T".into()),
+ ..CloudSource::default()
+ }),
+ )
+ .unwrap()
+ .id;
+ let doc = |port: u16, tags: &[&str], snippet_body: Option<&str>| {
+ let mut snippets =
+ vec![serde_json::json!({ "id": "up", "name": "Uptime", "body": "uptime" })];
+ if let Some(body) = snippet_body {
+ snippets.push(serde_json::json!({ "id": "logs", "name": "Logs", "body": body }));
+ }
+ mg_inventory::feed::parse(
+ "Ops",
+ serde_json::json!({
+ "version": 1,
+ "hosts": [
+ { "id": "bastion", "hostname": "bastion.example", "user": "ops", "group": "Bastions",
+ "host_keys": ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl"] },
+ { "id": "web", "hostname": "10.0.0.1", "port": port, "user": "ops", "jump": "bastion",
+ "tags": tags },
+ { "id": "loose", "hostname": "loose.example", "user": "ops" }
+ ],
+ "snippets": snippets
+ })
+ .to_string()
+ .as_bytes(),
+ )
+ .unwrap()
+ };
+ let first = doc(22, &["prod"], Some("journalctl -n 100"));
+ let r = apply(&mut v, &sid, &first.hosts).unwrap();
+ assert_eq!((r.added, r.updated), (3, 0));
+ assert_eq!(
+ apply_snippets(&mut v, &sid, &first.snippets).unwrap(),
+ (2, 0, 0)
+ );
+ let hosts = hosts_of(&v, &sid);
+ let id_of = |label: &str| {
+ hosts
+ .iter()
+ .find(|(_, h)| h.label == label)
+ .unwrap()
+ .0
+ .clone()
+ };
+ let web = id_of("web");
+ // Every device works out the same id.
+ assert_eq!(
+ web,
+ mg_store::derived_id(&[&target, &sid, "host", "feed:web"])
+ );
+ let ItemData::Host(h) = v.item(&web).unwrap().data else {
+ unreachable!()
+ };
+ assert_eq!(h.jump_ref, Some(id_of("bastion")));
+ assert_eq!(h.tags, ["prod"]);
+ assert_eq!(h.group_ref, None, "no group where the feed names none");
+ let ItemData::Host(b) = v.item(&id_of("bastion")).unwrap().data else {
+ unreachable!()
+ };
+ assert_eq!(b.source.unwrap().host_keys.len(), 1);
+ assert!(b.group_ref.is_some());
+
+ // The person tags it; the feed moves its port and swaps its tag.
+ let ItemData::Host(mut mine) = v.item(&web).unwrap().data else {
+ unreachable!()
+ };
+ mine.tags.push("mine".into());
+ save(&mut v, Some(&web), None, ItemData::Host(mine)).unwrap();
+ let second = doc(2222, &["fsn1"], None);
+ let r = apply(&mut v, &sid, &second.hosts).unwrap();
+ assert_eq!((r.added, r.updated, r.gone), (0, 1, 0));
+ let ItemData::Host(h) = v.item(&web).unwrap().data else {
+ unreachable!()
+ };
+ assert_eq!(
+ (h.port, h.tags.as_slice()),
+ (2222, &["mine".to_owned(), "fsn1".to_owned()][..])
+ );
+ // The snippet the feed dropped goes; nobody's edits are lost, it couldn't be edited.
+ assert_eq!(
+ apply_snippets(&mut v, &sid, &second.snippets).unwrap(),
+ (0, 0, 1)
+ );
+ let names: Vec = v
+ .items()
+ .unwrap()
+ .into_iter()
+ .filter_map(|i| match i.data {
+ ItemData::Snippet(s) => Some(s.name),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(names, ["Uptime"]);
+ }
}
diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs
index a4a7f8a..bdc4208 100644
--- a/apps/desktop/src-tauri/src/lib.rs
+++ b/apps/desktop/src-tauri/src/lib.rs
@@ -211,14 +211,20 @@ pub fn run() {
cloud::cloud_source_add,
cloud::cloud_source_settings,
cloud::cloud_add_key,
+ cloud::cloud_feed_add,
+ cloud::cloud_feed_settings,
+ cloud::cloud_auto_refresh,
agents::agents_list,
orgs::orgs_locked,
links::links_take,
+ links::links_peek,
+ links::links_add_invite,
host_import::import_preview,
host_import::import_hosts,
vault::snippets_list,
vault::snippet_save,
vault::snippet_delete,
+ vault::snippet_sent,
snippet_import::snippets_read_csv,
snippet_import::snippets_csv_example,
tunnels::forwards_list,
@@ -229,6 +235,7 @@ pub fn run() {
sync::sync_status,
sync::sync_presence,
password::password_strength,
+ password::password_suggest,
sync::sync_check,
sync::sync_start_registration,
sync::sync_enable,
@@ -266,6 +273,7 @@ pub fn run() {
orgs::org_invites,
orgs::org_invite_cancel,
orgs::invite_preview,
+ orgs::invite_peek,
orgs::invite_accept,
orgs::member_confirm,
orgs::member_role,
diff --git a/apps/desktop/src-tauri/src/links.rs b/apps/desktop/src-tauri/src/links.rs
index 9b44f8a..f9cdb40 100644
--- a/apps/desktop/src-tauri/src/links.rs
+++ b/apps/desktop/src-tauri/src/links.rs
@@ -7,7 +7,8 @@
//! - `morphgate://connect?host=…&port=…&user=…&label=…`: quick connect
//!
//! Dev builds answer `morphgate-dev://` instead. Links that arrive while the vault is locked wait
-//! here until the workspace takes them.
+//! here until the workspace takes them; the first screen looks at a waiting invite (and can add
+//! one pasted there) without taking it.
use std::sync::Mutex;
@@ -75,11 +76,7 @@ pub fn parse(url: &Url) -> Result {
let token = query("token")
.or_else(|| path.first().map(|s| (*s).to_owned()))
.ok_or("the invite link has no code")?;
- if !(16..=256).contains(&token.len())
- || !token
- .bytes()
- .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
- {
+ if !invite_code(&token) {
return Err("the invite code in the link isn't valid".into());
}
let server = query("server").map(|s| origin(&s)).transpose()?;
@@ -146,6 +143,46 @@ pub fn parse(url: &Url) -> Result {
}
}
+/// What an invite code looks like.
+fn invite_code(token: &str) -> bool {
+ (16..=256).contains(&token.len())
+ && token
+ .bytes()
+ .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
+}
+
+/// An invite pasted on the first screen: the code, the website's invite page
+/// (`https://…/invite/`), or a `morphgate://invite` link.
+pub fn pasted_invite(text: &str) -> Result {
+ let text = text.trim();
+ let not = || "that isn't an invite link or code".to_owned();
+ if invite_code(text) {
+ return Ok(Link::Invite {
+ token: text.into(),
+ server: None,
+ });
+ }
+ let url = Url::parse(text).map_err(|_| not())?;
+ if SCHEMES.contains(&url.scheme()) {
+ return match parse(&url)? {
+ link @ Link::Invite { .. } => Ok(link),
+ _ => Err(not()),
+ };
+ }
+ let token = match url
+ .path_segments()
+ .map(|p| p.collect::>())
+ .as_deref()
+ {
+ Some(["invite", token]) if invite_code(token) => (*token).to_owned(),
+ _ => return Err(not()),
+ };
+ Ok(Link::Invite {
+ token,
+ server: Some(origin(url.as_str())?),
+ })
+}
+
/// `https://host[:port]` (plain `http` only for this computer).
fn origin(server: &str) -> Result {
let bad = || "the server in the link isn't a sync server address".to_owned();
@@ -208,6 +245,33 @@ pub fn links_take(links: State<'_, Links>) -> Vec {
.unwrap_or_default()
}
+/// The links waiting for the workspace, left waiting (the first screen shows an invite).
+#[tauri::command]
+pub fn links_peek(links: State<'_, Links>) -> Vec {
+ links.0.lock().map(|l| l.clone()).unwrap_or_default()
+}
+
+/// An invite pasted on the first screen: it waits with the other links, so the workspace opens
+/// Teams with it once there's an account.
+#[tauri::command]
+pub fn links_add_invite(
+ app: AppHandle,
+ links: State<'_, Links>,
+ text: String,
+) -> Result<(), String> {
+ let link = pasted_invite(&text)?;
+ let mut pending = links.0.lock().map_err(|_| "links lock poisoned")?;
+ // One invite at a time: the newest replaces any waiting.
+ pending.retain(|r| !matches!(r.link, Some(Link::Invite { .. })));
+ pending.push(Received {
+ link: Some(link),
+ error: None,
+ });
+ drop(pending);
+ let _ = app.emit("links-pending", ());
+ Ok(())
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -218,6 +282,39 @@ mod tests {
const TOKEN: &str = "AbCdEfGhIjKlMnOpQrStUv_-0123456789";
+ #[test]
+ fn pasted_invites() {
+ let hosted = Link::Invite {
+ token: TOKEN.into(),
+ server: Some("https://morphgate.com".into()),
+ };
+ assert_eq!(
+ pasted_invite(&format!(" {TOKEN}\n")),
+ Ok(Link::Invite {
+ token: TOKEN.into(),
+ server: None
+ })
+ );
+ assert_eq!(
+ pasted_invite(&format!("https://morphgate.com/invite/{TOKEN}")),
+ Ok(hosted.clone())
+ );
+ assert_eq!(
+ pasted_invite(&format!(
+ "morphgate://invite?token={TOKEN}&server=https://morphgate.com"
+ )),
+ Ok(hosted)
+ );
+ for not in [
+ "hello",
+ "morphgate://sync",
+ "https://morphgate.com/account",
+ "http://evil.example/invite/AbCdEfGhIjKlMnOpQrStUv",
+ ] {
+ assert!(pasted_invite(not).is_err(), "{not}");
+ }
+ }
+
#[test]
fn invites() {
let expected = Link::Invite {
diff --git a/apps/desktop/src-tauri/src/offboard.rs b/apps/desktop/src-tauri/src/offboard.rs
index 216d144..de7f3cb 100644
--- a/apps/desktop/src-tauri/src/offboard.rs
+++ b/apps/desktop/src-tauri/src/offboard.rs
@@ -38,7 +38,8 @@ pub struct ReachView {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct SecretView {
- /// `password` (a host's saved password), `key` (a shared SSH key) or `vpn`.
+ /// `password` (a host's saved password), `key` (a shared SSH key), `vpn`, or `feed` (a
+ /// feed's token, to rotate where the feed is published).
kind: &'static str,
item_id: String,
label: String,
@@ -158,6 +159,16 @@ pub async fn offboard_summary(
used_by: Vec::new(),
});
}
+ ItemData::CloudSource(c) if c.token.is_some() => {
+ r.other += 1;
+ secrets.push(SecretView {
+ kind: "feed",
+ item_id: item.id.clone(),
+ label: c.label.clone(),
+ vault_name: name.clone(),
+ used_by: Vec::new(),
+ });
+ }
ItemData::MemberKey(_) => {}
_ => r.other += 1,
}
diff --git a/apps/desktop/src-tauri/src/orgs.rs b/apps/desktop/src-tauri/src/orgs.rs
index 6823162..47fb4d2 100644
--- a/apps/desktop/src-tauri/src/orgs.rs
+++ b/apps/desktop/src-tauri/src/orgs.rs
@@ -305,6 +305,16 @@ pub async fn invite_preview(
.await
}
+/// What an invite is for, before there's an account (the first screen): `server` is the one the
+/// link names, or the one chosen there.
+#[tauri::command]
+pub async fn invite_peek(server: String, token: String) -> Result {
+ let client = SyncClient::new(&server).map_err(|e| e.to_string())?;
+ sharing::preview_invite(&client, &token)
+ .await
+ .map_err(|e| e.to_string())
+}
+
#[tauri::command]
pub async fn invite_accept(
vault: State<'_, VaultState>,
diff --git a/apps/desktop/src-tauri/src/password.rs b/apps/desktop/src-tauri/src/password.rs
index 70f395c..45cd49f 100644
--- a/apps/desktop/src-tauri/src/password.rs
+++ b/apps/desktop/src-tauri/src/password.rs
@@ -67,6 +67,57 @@ pub fn password_strength(password: String) -> Strength {
strength(&password)
}
+/// EFF's long wordlist (`wordlist/README.md`): dice numbers, a tab, a word.
+const WORDLIST: &str = include_str!("../wordlist/eff_large_wordlist.txt");
+/// Words in a suggestion: about 64 bits from the 7772 used.
+const SUGGESTED_WORDS: usize = 5;
+
+/// The list's words, but for the four with a hyphen (the separator).
+fn words() -> Vec<&'static str> {
+ WORDLIST
+ .lines()
+ .filter_map(|l| l.split_once('\t').map(|(_, w)| w.trim()))
+ .filter(|w| !w.is_empty() && w.bytes().all(|b| b.is_ascii_lowercase()))
+ .collect()
+}
+
+/// A number below `n`, every one as likely (no modulo bias).
+fn below(n: u32) -> Result {
+ let zone = u32::MAX - u32::MAX % n;
+ loop {
+ let mut b = [0u8; 4];
+ getrandom::fill(&mut b).map_err(|e| format!("no randomness from the system: {e}"))?;
+ let x = u32::from_le_bytes(b);
+ if x < zone {
+ return Ok(x % n);
+ }
+ }
+}
+
+/// Five words at random, joined by hyphens: easy to write down and type, hard to guess. Ones the
+/// strength check would refuse (rarely: a run of very common words) are drawn again.
+pub(crate) fn suggest() -> Result {
+ let list = words();
+ let n = u32::try_from(list.len()).map_err(|_| "the wordlist is too long")?;
+ for _ in 0..16 {
+ let mut picked = Vec::with_capacity(SUGGESTED_WORDS);
+ for _ in 0..SUGGESTED_WORDS {
+ picked.push(list[below(n)? as usize]);
+ }
+ let password = picked.join("-");
+ if strength(&password).ok {
+ return Ok(password);
+ }
+ }
+ Err("couldn't come up with a strong password; type one instead".into())
+}
+
+/// A master password to suggest.
+#[tauri::command]
+pub fn password_suggest() -> Result {
+ suggest()
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -92,4 +143,24 @@ mod tests {
let s = strength("P@ssw0rd123");
assert!(!s.ok && s.warning.is_some());
}
+
+ #[test]
+ fn suggestions_are_five_listed_words_that_pass() {
+ let list = words();
+ assert_eq!(
+ list.len(),
+ 7776 - 4,
+ "the list as published, but for the hyphenated four"
+ );
+ let mut seen = std::collections::HashSet::new();
+ for _ in 0..50 {
+ let p = suggest().unwrap();
+ let parts: Vec<&str> = p.split('-').collect();
+ assert_eq!(parts.len(), SUGGESTED_WORDS, "{p}");
+ assert!(parts.iter().all(|w| list.contains(w)), "{p}");
+ assert!(check(&p).is_ok(), "{p}");
+ seen.insert(p);
+ }
+ assert_eq!(seen.len(), 50, "suggestions repeat");
+ }
}
diff --git a/apps/desktop/src-tauri/src/ssh.rs b/apps/desktop/src-tauri/src/ssh.rs
index 0a1924e..f57e813 100644
--- a/apps/desktop/src-tauri/src/ssh.rs
+++ b/apps/desktop/src-tauri/src/ssh.rs
@@ -184,6 +184,7 @@ pub(crate) fn resolve(target: Target, vault: &VaultState) -> Result c.label,
+ _ => "its feed".into(),
+ },
+ });
Ok(Hop {
host: host.hostname,
port: host.port,
user: host.user,
auth,
via_command: host.jump_command,
+ listed_keys,
})
}
@@ -520,6 +534,7 @@ pub async fn ssh_connect(
auth: target.auth,
jumps,
via_command: target.via_command,
+ listed_keys: target.listed_keys,
dialer,
term: "xterm-256color".into(),
size: request.size,
diff --git a/apps/desktop/src-tauri/src/sync.rs b/apps/desktop/src-tauri/src/sync.rs
index 3ef31b7..0b53920 100644
--- a/apps/desktop/src-tauri/src/sync.rs
+++ b/apps/desktop/src-tauri/src/sync.rs
@@ -121,6 +121,8 @@ pub struct SyncStatusView {
syncing: bool,
/// Sync is on but the server ended this device's session: sign in again.
signed_out: bool,
+ /// Organizations whose vaults wait for this account to turn on two-step verification.
+ totp_required: Vec,
/// Organizations whose vaults wait for this device to sign in at their identity provider.
sso_required: Vec,
/// Organizations whose vaults wait for this device to be approved (or that denied it).
@@ -323,6 +325,14 @@ fn status(vault: &VaultState, sync: &SyncState, syncing: bool) -> SyncStatusView
.map_or_else(|| "Your organization".into(), |o| o.name.clone())
};
let held = sync.held.lock().map(|h| h.clone()).unwrap_or_default();
+ let totp_required = held
+ .iter()
+ .filter(|(_, why)| *why == Hold::Totp)
+ .map(|(id, _)| SsoNeed {
+ org_id: id.clone(),
+ org_name: name(id),
+ })
+ .collect();
let sso_required = held
.iter()
.filter(|(_, why)| *why == Hold::Sso)
@@ -333,7 +343,7 @@ fn status(vault: &VaultState, sync: &SyncState, syncing: bool) -> SyncStatusView
.collect();
let approval_required = held
.iter()
- .filter(|(_, why)| *why != Hold::Sso)
+ .filter(|(_, why)| matches!(why, Hold::Approval | Hold::Denied))
.map(|(id, why)| ApprovalNeed {
org_id: id.clone(),
org_name: name(id),
@@ -383,6 +393,7 @@ fn status(vault: &VaultState, sync: &SyncState, syncing: bool) -> SyncStatusView
last_sync_at: info.as_ref().and_then(|i| i.last_sync_at),
last_error: info.and_then(|i| i.last_error),
syncing,
+ totp_required,
sso_required,
approval_required,
approvals_waiting,
@@ -1271,7 +1282,10 @@ pub async fn sync_totp_enable(
let client = signed_in(&state, &vault).await?;
account::enable_totp(&vault.0, client, code)
.await
- .map_err(|e| e.to_string())
+ .map_err(|e| e.to_string())?;
+ // Teams that require it open their vaults: the next round brings them.
+ state.poke.notify_one();
+ Ok(())
}
/// Turns two-step verification off (master password and a current code).
@@ -1286,7 +1300,10 @@ pub async fn sync_totp_disable(
let client = signed_in(&state, &vault).await?;
account::disable_totp(&vault.0, client, Zeroizing::new(password), code)
.await
- .map_err(|e| e.to_string())
+ .map_err(|e| e.to_string())?;
+ // Teams that require it hold their vaults back from now on: the next round says so.
+ state.poke.notify_one();
+ Ok(())
}
/// Mails a code to the address the account should move to.
diff --git a/apps/desktop/src-tauri/src/vault.rs b/apps/desktop/src-tauri/src/vault.rs
index 06a1d16..7543878 100644
--- a/apps/desktop/src-tauri/src/vault.rs
+++ b/apps/desktop/src-tauri/src/vault.rs
@@ -330,6 +330,8 @@ pub struct HostSourceView {
state: Option,
/// Reached through its cloud: `aws-ssm`, `aws-eice` or `gcp-iap`.
via: Option,
+ /// The feed that lists its host keys (which decide instead of a pinned key), if one does.
+ listed_by: Option,
}
#[derive(Serialize)]
@@ -373,6 +375,7 @@ impl HostView {
no_forwarding: s.no_forwarding,
state: s.state,
via: s.reach.map(|r| r.kind),
+ listed_by: (!s.host_keys.is_empty()).then(String::new),
}),
group_id: h.group_ref,
identity_id: h.identity_ref,
@@ -409,6 +412,9 @@ impl HostView {
}) = v.item(&s.source_id)
{
s.provider = c.provider;
+ if let Some(by) = s.listed_by.as_mut() {
+ *by = c.label;
+ }
}
Some(view)
}
@@ -1027,6 +1033,18 @@ pub struct SnippetView {
body: String,
tags: Vec,
updated_at: i64,
+ /// The feed that keeps it (it's read-only here), if one does.
+ source: Option,
+ /// Its feed changed the body since this device last pasted or ran it: ask first.
+ changed: bool,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SnippetSourceView {
+ source_id: String,
+ /// The feed's name.
+ label: String,
}
#[derive(Deserialize)]
@@ -1041,22 +1059,79 @@ pub struct SnippetInput {
tags: Vec,
}
+/// Where this device keeps the hash of the body it last sent of a feed's snippet (not synced).
+fn sent_setting(id: &str) -> String {
+ format!("snippet-sent:{id}")
+}
+
+fn body_hash(body: &str) -> String {
+ use sha2::{Digest, Sha256};
+ Sha256::digest(body.as_bytes())
+ .iter()
+ .map(|b| format!("{b:02x}"))
+ .collect()
+}
+
+fn snippet_view(
+ v: &LocalVault,
+ id: String,
+ vault_id: String,
+ updated_at: i64,
+ s: Snippet,
+) -> SnippetView {
+ let source = s.source.as_ref().map(|src| SnippetSourceView {
+ source_id: src.source_ref.clone(),
+ label: match v.item(&src.source_ref).map(|i| i.data) {
+ Ok(ItemData::CloudSource(c)) => c.label,
+ _ => "its feed".into(),
+ },
+ });
+ let changed = source.is_some()
+ && v.setting(&sent_setting(&id))
+ .ok()
+ .flatten()
+ .is_some_and(|sent| sent != body_hash(&s.body));
+ SnippetView {
+ id,
+ vault_id,
+ name: s.name,
+ body: s.body,
+ tags: s.tags,
+ updated_at,
+ source,
+ changed,
+ }
+}
+
+/// A feed's snippet can't be changed here: "make a copy" instead.
+fn from_feed(v: &LocalVault, id: &str) -> Result<(), String> {
+ match v.item(id).map(|i| i.data) {
+ Ok(ItemData::Snippet(Snippet {
+ source: Some(src),
+ name,
+ ..
+ })) => {
+ let feed = match v.item(&src.source_ref).map(|i| i.data) {
+ Ok(ItemData::CloudSource(c)) => c.label,
+ _ => "its feed".into(),
+ };
+ Err(format!(
+ "\"{name}\" comes from {feed}: it changes there, or make a copy to change it here"
+ ))
+ }
+ _ => Ok(()),
+ }
+}
+
#[tauri::command]
pub fn snippets_list(vault: State<'_, VaultState>) -> Result, String> {
- let mut snippets: Vec = vault
- .guard()?
+ let v = vault.guard()?;
+ let mut snippets: Vec = v
.items()
.map_err(err)?
.into_iter()
.filter_map(|i| match i.data {
- ItemData::Snippet(s) => Some(SnippetView {
- id: i.id,
- vault_id: i.vault_id,
- name: s.name,
- body: s.body,
- tags: s.tags,
- updated_at: i.updated_at,
- }),
+ ItemData::Snippet(s) => Some(snippet_view(&v, i.id, i.vault_id, i.updated_at, s)),
_ => None,
})
.collect();
@@ -1078,40 +1153,59 @@ pub fn snippet_save(
.map(|t| t.trim().to_owned())
.filter(|t| !t.is_empty())
.collect(),
+ source: None,
};
+ let mut v = vault.guard()?;
+ if let Some(id) = &input.id {
+ from_feed(&v, id)?;
+ }
let Item {
id,
vault_id,
updated_at,
data,
} = save(
- &mut *vault.guard()?,
+ &mut v,
input.id.as_deref(),
input.vault_id.as_deref(),
ItemData::Snippet(snippet),
)
.map_err(err)?;
- vault.changed();
- match data {
- ItemData::Snippet(s) => Ok(SnippetView {
- id,
- vault_id,
- name: s.name,
- body: s.body,
- tags: s.tags,
- updated_at,
- }),
+ let view = match data {
+ ItemData::Snippet(s) => snippet_view(&v, id, vault_id, updated_at, s),
_ => unreachable!("upsert returns the data it was given"),
- }
+ };
+ drop(v);
+ vault.changed();
+ Ok(view)
}
#[tauri::command]
pub fn snippet_delete(vault: State<'_, VaultState>, id: String) -> Result<(), String> {
- vault.guard()?.delete(&id).map_err(err)?;
+ {
+ let mut v = vault.guard()?;
+ from_feed(&v, &id)?;
+ v.delete(&id).map_err(err)?;
+ }
vault.changed();
Ok(())
}
+/// This device pasted or ran a feed's snippet: what it sent is what it'll compare the next
+/// change with.
+#[tauri::command]
+pub fn snippet_sent(vault: State<'_, VaultState>, id: String) -> Result<(), String> {
+ let v = vault.guard()?;
+ let ItemData::Snippet(s) = v.item(&id).map_err(err)?.data else {
+ return Err("not a snippet".into());
+ };
+ if s.source.is_some() {
+ v.set_setting(&sent_setting(&id), Some(&body_hash(&s.body)))
+ .map_err(err)?;
+ }
+ Ok(())
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/apps/desktop/src-tauri/tauri.android.conf.json b/apps/desktop/src-tauri/tauri.android.conf.json
index abd5b37..0f4f33a 100644
--- a/apps/desktop/src-tauri/tauri.android.conf.json
+++ b/apps/desktop/src-tauri/tauri.android.conf.json
@@ -2,7 +2,7 @@
"identifier": "com.morphgate.app",
"bundle": {
"android": {
- "versionCode": 20000
+ "versionCode": 20100
}
}
}
diff --git a/apps/desktop/src-tauri/wordlist/README.md b/apps/desktop/src-tauri/wordlist/README.md
new file mode 100644
index 0000000..21f87bd
--- /dev/null
+++ b/apps/desktop/src-tauri/wordlist/README.md
@@ -0,0 +1,11 @@
+# EFF's long wordlist
+
+`eff_large_wordlist.txt` is the [Electronic Frontier Foundation](https://www.eff.org/dice)'s long
+wordlist for passphrases, as published at
+ (7776 words, sha256
+`addd35536511597a02fa0a9ff1e5284677b8883b83e986e43f15a3db996b903e`), under the
+[Creative Commons Attribution 3.0 United States License](https://creativecommons.org/licenses/by/3.0/us/).
+
+"Suggest one" under a new master password (`password_suggest` in `src/password.rs`) picks five of
+its words at random. It leaves out the four with a hyphen, so that a hyphen always separates words.
+The app's open-source licenses list it (`scripts/licenses.mjs`).
diff --git a/apps/desktop/src-tauri/wordlist/eff_large_wordlist.txt b/apps/desktop/src-tauri/wordlist/eff_large_wordlist.txt
new file mode 100644
index 0000000..43f88a9
--- /dev/null
+++ b/apps/desktop/src-tauri/wordlist/eff_large_wordlist.txt
@@ -0,0 +1,7776 @@
+11111 abacus
+11112 abdomen
+11113 abdominal
+11114 abide
+11115 abiding
+11116 ability
+11121 ablaze
+11122 able
+11123 abnormal
+11124 abrasion
+11125 abrasive
+11126 abreast
+11131 abridge
+11132 abroad
+11133 abruptly
+11134 absence
+11135 absentee
+11136 absently
+11141 absinthe
+11142 absolute
+11143 absolve
+11144 abstain
+11145 abstract
+11146 absurd
+11151 accent
+11152 acclaim
+11153 acclimate
+11154 accompany
+11155 account
+11156 accuracy
+11161 accurate
+11162 accustom
+11163 acetone
+11164 achiness
+11165 aching
+11166 acid
+11211 acorn
+11212 acquaint
+11213 acquire
+11214 acre
+11215 acrobat
+11216 acronym
+11221 acting
+11222 action
+11223 activate
+11224 activator
+11225 active
+11226 activism
+11231 activist
+11232 activity
+11233 actress
+11234 acts
+11235 acutely
+11236 acuteness
+11241 aeration
+11242 aerobics
+11243 aerosol
+11244 aerospace
+11245 afar
+11246 affair
+11251 affected
+11252 affecting
+11253 affection
+11254 affidavit
+11255 affiliate
+11256 affirm
+11261 affix
+11262 afflicted
+11263 affluent
+11264 afford
+11265 affront
+11266 aflame
+11311 afloat
+11312 aflutter
+11313 afoot
+11314 afraid
+11315 afterglow
+11316 afterlife
+11321 aftermath
+11322 aftermost
+11323 afternoon
+11324 aged
+11325 ageless
+11326 agency
+11331 agenda
+11332 agent
+11333 aggregate
+11334 aghast
+11335 agile
+11336 agility
+11341 aging
+11342 agnostic
+11343 agonize
+11344 agonizing
+11345 agony
+11346 agreeable
+11351 agreeably
+11352 agreed
+11353 agreeing
+11354 agreement
+11355 aground
+11356 ahead
+11361 ahoy
+11362 aide
+11363 aids
+11364 aim
+11365 ajar
+11366 alabaster
+11411 alarm
+11412 albatross
+11413 album
+11414 alfalfa
+11415 algebra
+11416 algorithm
+11421 alias
+11422 alibi
+11423 alienable
+11424 alienate
+11425 aliens
+11426 alike
+11431 alive
+11432 alkaline
+11433 alkalize
+11434 almanac
+11435 almighty
+11436 almost
+11441 aloe
+11442 aloft
+11443 aloha
+11444 alone
+11445 alongside
+11446 aloof
+11451 alphabet
+11452 alright
+11453 although
+11454 altitude
+11455 alto
+11456 aluminum
+11461 alumni
+11462 always
+11463 amaretto
+11464 amaze
+11465 amazingly
+11466 amber
+11511 ambiance
+11512 ambiguity
+11513 ambiguous
+11514 ambition
+11515 ambitious
+11516 ambulance
+11521 ambush
+11522 amendable
+11523 amendment
+11524 amends
+11525 amenity
+11526 amiable
+11531 amicably
+11532 amid
+11533 amigo
+11534 amino
+11535 amiss
+11536 ammonia
+11541 ammonium
+11542 amnesty
+11543 amniotic
+11544 among
+11545 amount
+11546 amperage
+11551 ample
+11552 amplifier
+11553 amplify
+11554 amply
+11555 amuck
+11556 amulet
+11561 amusable
+11562 amused
+11563 amusement
+11564 amuser
+11565 amusing
+11566 anaconda
+11611 anaerobic
+11612 anagram
+11613 anatomist
+11614 anatomy
+11615 anchor
+11616 anchovy
+11621 ancient
+11622 android
+11623 anemia
+11624 anemic
+11625 aneurism
+11626 anew
+11631 angelfish
+11632 angelic
+11633 anger
+11634 angled
+11635 angler
+11636 angles
+11641 angling
+11642 angrily
+11643 angriness
+11644 anguished
+11645 angular
+11646 animal
+11651 animate
+11652 animating
+11653 animation
+11654 animator
+11655 anime
+11656 animosity
+11661 ankle
+11662 annex
+11663 annotate
+11664 announcer
+11665 annoying
+11666 annually
+12111 annuity
+12112 anointer
+12113 another
+12114 answering
+12115 antacid
+12116 antarctic
+12121 anteater
+12122 antelope
+12123 antennae
+12124 anthem
+12125 anthill
+12126 anthology
+12131 antibody
+12132 antics
+12133 antidote
+12134 antihero
+12135 antiquely
+12136 antiques
+12141 antiquity
+12142 antirust
+12143 antitoxic
+12144 antitrust
+12145 antiviral
+12146 antivirus
+12151 antler
+12152 antonym
+12153 antsy
+12154 anvil
+12155 anybody
+12156 anyhow
+12161 anymore
+12162 anyone
+12163 anyplace
+12164 anything
+12165 anytime
+12166 anyway
+12211 anywhere
+12212 aorta
+12213 apache
+12214 apostle
+12215 appealing
+12216 appear
+12221 appease
+12222 appeasing
+12223 appendage
+12224 appendix
+12225 appetite
+12226 appetizer
+12231 applaud
+12232 applause
+12233 apple
+12234 appliance
+12235 applicant
+12236 applied
+12241 apply
+12242 appointee
+12243 appraisal
+12244 appraiser
+12245 apprehend
+12246 approach
+12251 approval
+12252 approve
+12253 apricot
+12254 april
+12255 apron
+12256 aptitude
+12261 aptly
+12262 aqua
+12263 aqueduct
+12264 arbitrary
+12265 arbitrate
+12266 ardently
+12311 area
+12312 arena
+12313 arguable
+12314 arguably
+12315 argue
+12316 arise
+12321 armadillo
+12322 armband
+12323 armchair
+12324 armed
+12325 armful
+12326 armhole
+12331 arming
+12332 armless
+12333 armoire
+12334 armored
+12335 armory
+12336 armrest
+12341 army
+12342 aroma
+12343 arose
+12344 around
+12345 arousal
+12346 arrange
+12351 array
+12352 arrest
+12353 arrival
+12354 arrive
+12355 arrogance
+12356 arrogant
+12361 arson
+12362 art
+12363 ascend
+12364 ascension
+12365 ascent
+12366 ascertain
+12411 ashamed
+12412 ashen
+12413 ashes
+12414 ashy
+12415 aside
+12416 askew
+12421 asleep
+12422 asparagus
+12423 aspect
+12424 aspirate
+12425 aspire
+12426 aspirin
+12431 astonish
+12432 astound
+12433 astride
+12434 astrology
+12435 astronaut
+12436 astronomy
+12441 astute
+12442 atlantic
+12443 atlas
+12444 atom
+12445 atonable
+12446 atop
+12451 atrium
+12452 atrocious
+12453 atrophy
+12454 attach
+12455 attain
+12456 attempt
+12461 attendant
+12462 attendee
+12463 attention
+12464 attentive
+12465 attest
+12466 attic
+12511 attire
+12512 attitude
+12513 attractor
+12514 attribute
+12515 atypical
+12516 auction
+12521 audacious
+12522 audacity
+12523 audible
+12524 audibly
+12525 audience
+12526 audio
+12531 audition
+12532 augmented
+12533 august
+12534 authentic
+12535 author
+12536 autism
+12541 autistic
+12542 autograph
+12543 automaker
+12544 automated
+12545 automatic
+12546 autopilot
+12551 available
+12552 avalanche
+12553 avatar
+12554 avenge
+12555 avenging
+12556 avenue
+12561 average
+12562 aversion
+12563 avert
+12564 aviation
+12565 aviator
+12566 avid
+12611 avoid
+12612 await
+12613 awaken
+12614 award
+12615 aware
+12616 awhile
+12621 awkward
+12622 awning
+12623 awoke
+12624 awry
+12625 axis
+12626 babble
+12631 babbling
+12632 babied
+12633 baboon
+12634 backache
+12635 backboard
+12636 backboned
+12641 backdrop
+12642 backed
+12643 backer
+12644 backfield
+12645 backfire
+12646 backhand
+12651 backing
+12652 backlands
+12653 backlash
+12654 backless
+12655 backlight
+12656 backlit
+12661 backlog
+12662 backpack
+12663 backpedal
+12664 backrest
+12665 backroom
+12666 backshift
+13111 backside
+13112 backslid
+13113 backspace
+13114 backspin
+13115 backstab
+13116 backstage
+13121 backtalk
+13122 backtrack
+13123 backup
+13124 backward
+13125 backwash
+13126 backwater
+13131 backyard
+13132 bacon
+13133 bacteria
+13134 bacterium
+13135 badass
+13136 badge
+13141 badland
+13142 badly
+13143 badness
+13144 baffle
+13145 baffling
+13146 bagel
+13151 bagful
+13152 baggage
+13153 bagged
+13154 baggie
+13155 bagginess
+13156 bagging
+13161 baggy
+13162 bagpipe
+13163 baguette
+13164 baked
+13165 bakery
+13166 bakeshop
+13211 baking
+13212 balance
+13213 balancing
+13214 balcony
+13215 balmy
+13216 balsamic
+13221 bamboo
+13222 banana
+13223 banish
+13224 banister
+13225 banjo
+13226 bankable
+13231 bankbook
+13232 banked
+13233 banker
+13234 banking
+13235 banknote
+13236 bankroll
+13241 banner
+13242 bannister
+13243 banshee
+13244 banter
+13245 barbecue
+13246 barbed
+13251 barbell
+13252 barber
+13253 barcode
+13254 barge
+13255 bargraph
+13256 barista
+13261 baritone
+13262 barley
+13263 barmaid
+13264 barman
+13265 barn
+13266 barometer
+13311 barrack
+13312 barracuda
+13313 barrel
+13314 barrette
+13315 barricade
+13316 barrier
+13321 barstool
+13322 bartender
+13323 barterer
+13324 bash
+13325 basically
+13326 basics
+13331 basil
+13332 basin
+13333 basis
+13334 basket
+13335 batboy
+13336 batch
+13341 bath
+13342 baton
+13343 bats
+13344 battalion
+13345 battered
+13346 battering
+13351 battery
+13352 batting
+13353 battle
+13354 bauble
+13355 bazooka
+13356 blabber
+13361 bladder
+13362 blade
+13363 blah
+13364 blame
+13365 blaming
+13366 blanching
+13411 blandness
+13412 blank
+13413 blaspheme
+13414 blasphemy
+13415 blast
+13416 blatancy
+13421 blatantly
+13422 blazer
+13423 blazing
+13424 bleach
+13425 bleak
+13426 bleep
+13431 blemish
+13432 blend
+13433 bless
+13434 blighted
+13435 blimp
+13436 bling
+13441 blinked
+13442 blinker
+13443 blinking
+13444 blinks
+13445 blip
+13446 blissful
+13451 blitz
+13452 blizzard
+13453 bloated
+13454 bloating
+13455 blob
+13456 blog
+13461 bloomers
+13462 blooming
+13463 blooper
+13464 blot
+13465 blouse
+13466 blubber
+13511 bluff
+13512 bluish
+13513 blunderer
+13514 blunt
+13515 blurb
+13516 blurred
+13521 blurry
+13522 blurt
+13523 blush
+13524 blustery
+13525 boaster
+13526 boastful
+13531 boasting
+13532 boat
+13533 bobbed
+13534 bobbing
+13535 bobble
+13536 bobcat
+13541 bobsled
+13542 bobtail
+13543 bodacious
+13544 body
+13545 bogged
+13546 boggle
+13551 bogus
+13552 boil
+13553 bok
+13554 bolster
+13555 bolt
+13556 bonanza
+13561 bonded
+13562 bonding
+13563 bondless
+13564 boned
+13565 bonehead
+13566 boneless
+13611 bonelike
+13612 boney
+13613 bonfire
+13614 bonnet
+13615 bonsai
+13616 bonus
+13621 bony
+13622 boogeyman
+13623 boogieman
+13624 book
+13625 boondocks
+13626 booted
+13631 booth
+13632 bootie
+13633 booting
+13634 bootlace
+13635 bootleg
+13636 boots
+13641 boozy
+13642 borax
+13643 boring
+13644 borough
+13645 borrower
+13646 borrowing
+13651 boss
+13652 botanical
+13653 botanist
+13654 botany
+13655 botch
+13656 both
+13661 bottle
+13662 bottling
+13663 bottom
+13664 bounce
+13665 bouncing
+13666 bouncy
+14111 bounding
+14112 boundless
+14113 bountiful
+14114 bovine
+14115 boxcar
+14116 boxer
+14121 boxing
+14122 boxlike
+14123 boxy
+14124 breach
+14125 breath
+14126 breeches
+14131 breeching
+14132 breeder
+14133 breeding
+14134 breeze
+14135 breezy
+14136 brethren
+14141 brewery
+14142 brewing
+14143 briar
+14144 bribe
+14145 brick
+14146 bride
+14151 bridged
+14152 brigade
+14153 bright
+14154 brilliant
+14155 brim
+14156 bring
+14161 brink
+14162 brisket
+14163 briskly
+14164 briskness
+14165 bristle
+14166 brittle
+14211 broadband
+14212 broadcast
+14213 broaden
+14214 broadly
+14215 broadness
+14216 broadside
+14221 broadways
+14222 broiler
+14223 broiling
+14224 broken
+14225 broker
+14226 bronchial
+14231 bronco
+14232 bronze
+14233 bronzing
+14234 brook
+14235 broom
+14236 brought
+14241 browbeat
+14242 brownnose
+14243 browse
+14244 browsing
+14245 bruising
+14246 brunch
+14251 brunette
+14252 brunt
+14253 brush
+14254 brussels
+14255 brute
+14256 brutishly
+14261 bubble
+14262 bubbling
+14263 bubbly
+14264 buccaneer
+14265 bucked
+14266 bucket
+14311 buckle
+14312 buckshot
+14313 buckskin
+14314 bucktooth
+14315 buckwheat
+14316 buddhism
+14321 buddhist
+14322 budding
+14323 buddy
+14324 budget
+14325 buffalo
+14326 buffed
+14331 buffer
+14332 buffing
+14333 buffoon
+14334 buggy
+14335 bulb
+14336 bulge
+14341 bulginess
+14342 bulgur
+14343 bulk
+14344 bulldog
+14345 bulldozer
+14346 bullfight
+14351 bullfrog
+14352 bullhorn
+14353 bullion
+14354 bullish
+14355 bullpen
+14356 bullring
+14361 bullseye
+14362 bullwhip
+14363 bully
+14364 bunch
+14365 bundle
+14366 bungee
+14411 bunion
+14412 bunkbed
+14413 bunkhouse
+14414 bunkmate
+14415 bunny
+14416 bunt
+14421 busboy
+14422 bush
+14423 busily
+14424 busload
+14425 bust
+14426 busybody
+14431 buzz
+14432 cabana
+14433 cabbage
+14434 cabbie
+14435 cabdriver
+14436 cable
+14441 caboose
+14442 cache
+14443 cackle
+14444 cacti
+14445 cactus
+14446 caddie
+14451 caddy
+14452 cadet
+14453 cadillac
+14454 cadmium
+14455 cage
+14456 cahoots
+14461 cake
+14462 calamari
+14463 calamity
+14464 calcium
+14465 calculate
+14466 calculus
+14511 caliber
+14512 calibrate
+14513 calm
+14514 caloric
+14515 calorie
+14516 calzone
+14521 camcorder
+14522 cameo
+14523 camera
+14524 camisole
+14525 camper
+14526 campfire
+14531 camping
+14532 campsite
+14533 campus
+14534 canal
+14535 canary
+14536 cancel
+14541 candied
+14542 candle
+14543 candy
+14544 cane
+14545 canine
+14546 canister
+14551 cannabis
+14552 canned
+14553 canning
+14554 cannon
+14555 cannot
+14556 canola
+14561 canon
+14562 canopener
+14563 canopy
+14564 canteen
+14565 canyon
+14566 capable
+14611 capably
+14612 capacity
+14613 cape
+14614 capillary
+14615 capital
+14616 capitol
+14621 capped
+14622 capricorn
+14623 capsize
+14624 capsule
+14625 caption
+14626 captivate
+14631 captive
+14632 captivity
+14633 capture
+14634 caramel
+14635 carat
+14636 caravan
+14641 carbon
+14642 cardboard
+14643 carded
+14644 cardiac
+14645 cardigan
+14646 cardinal
+14651 cardstock
+14652 carefully
+14653 caregiver
+14654 careless
+14655 caress
+14656 caretaker
+14661 cargo
+14662 caring
+14663 carless
+14664 carload
+14665 carmaker
+14666 carnage
+15111 carnation
+15112 carnival
+15113 carnivore
+15114 carol
+15115 carpenter
+15116 carpentry
+15121 carpool
+15122 carport
+15123 carried
+15124 carrot
+15125 carrousel
+15126 carry
+15131 cartel
+15132 cartload
+15133 carton
+15134 cartoon
+15135 cartridge
+15136 cartwheel
+15141 carve
+15142 carving
+15143 carwash
+15144 cascade
+15145 case
+15146 cash
+15151 casing
+15152 casino
+15153 casket
+15154 cassette
+15155 casually
+15156 casualty
+15161 catacomb
+15162 catalog
+15163 catalyst
+15164 catalyze
+15165 catapult
+15166 cataract
+15211 catatonic
+15212 catcall
+15213 catchable
+15214 catcher
+15215 catching
+15216 catchy
+15221 caterer
+15222 catering
+15223 catfight
+15224 catfish
+15225 cathedral
+15226 cathouse
+15231 catlike
+15232 catnap
+15233 catnip
+15234 catsup
+15235 cattail
+15236 cattishly
+15241 cattle
+15242 catty
+15243 catwalk
+15244 caucasian
+15245 caucus
+15246 causal
+15251 causation
+15252 cause
+15253 causing
+15254 cauterize
+15255 caution
+15256 cautious
+15261 cavalier
+15262 cavalry
+15263 caviar
+15264 cavity
+15265 cedar
+15266 celery
+15311 celestial
+15312 celibacy
+15313 celibate
+15314 celtic
+15315 cement
+15316 census
+15321 ceramics
+15322 ceremony
+15323 certainly
+15324 certainty
+15325 certified
+15326 certify
+15331 cesarean
+15332 cesspool
+15333 chafe
+15334 chaffing
+15335 chain
+15336 chair
+15341 chalice
+15342 challenge
+15343 chamber
+15344 chamomile
+15345 champion
+15346 chance
+15351 change
+15352 channel
+15353 chant
+15354 chaos
+15355 chaperone
+15356 chaplain
+15361 chapped
+15362 chaps
+15363 chapter
+15364 character
+15365 charbroil
+15366 charcoal
+15411 charger
+15412 charging
+15413 chariot
+15414 charity
+15415 charm
+15416 charred
+15421 charter
+15422 charting
+15423 chase
+15424 chasing
+15425 chaste
+15426 chastise
+15431 chastity
+15432 chatroom
+15433 chatter
+15434 chatting
+15435 chatty
+15436 cheating
+15441 cheddar
+15442 cheek
+15443 cheer
+15444 cheese
+15445 cheesy
+15446 chef
+15451 chemicals
+15452 chemist
+15453 chemo
+15454 cherisher
+15455 cherub
+15456 chess
+15461 chest
+15462 chevron
+15463 chevy
+15464 chewable
+15465 chewer
+15466 chewing
+15511 chewy
+15512 chief
+15513 chihuahua
+15514 childcare
+15515 childhood
+15516 childish
+15521 childless
+15522 childlike
+15523 chili
+15524 chill
+15525 chimp
+15526 chip
+15531 chirping
+15532 chirpy
+15533 chitchat
+15534 chivalry
+15535 chive
+15536 chloride
+15541 chlorine
+15542 choice
+15543 chokehold
+15544 choking
+15545 chomp
+15546 chooser
+15551 choosing
+15552 choosy
+15553 chop
+15554 chosen
+15555 chowder
+15556 chowtime
+15561 chrome
+15562 chubby
+15563 chuck
+15564 chug
+15565 chummy
+15566 chump
+15611 chunk
+15612 churn
+15613 chute
+15614 cider
+15615 cilantro
+15616 cinch
+15621 cinema
+15622 cinnamon
+15623 circle
+15624 circling
+15625 circular
+15626 circulate
+15631 circus
+15632 citable
+15633 citadel
+15634 citation
+15635 citizen
+15636 citric
+15641 citrus
+15642 city
+15643 civic
+15644 civil
+15645 clad
+15646 claim
+15651 clambake
+15652 clammy
+15653 clamor
+15654 clamp
+15655 clamshell
+15656 clang
+15661 clanking
+15662 clapped
+15663 clapper
+15664 clapping
+15665 clarify
+15666 clarinet
+16111 clarity
+16112 clash
+16113 clasp
+16114 class
+16115 clatter
+16116 clause
+16121 clavicle
+16122 claw
+16123 clay
+16124 clean
+16125 clear
+16126 cleat
+16131 cleaver
+16132 cleft
+16133 clench
+16134 clergyman
+16135 clerical
+16136 clerk
+16141 clever
+16142 clicker
+16143 client
+16144 climate
+16145 climatic
+16146 cling
+16151 clinic
+16152 clinking
+16153 clip
+16154 clique
+16155 cloak
+16156 clobber
+16161 clock
+16162 clone
+16163 cloning
+16164 closable
+16165 closure
+16166 clothes
+16211 clothing
+16212 cloud
+16213 clover
+16214 clubbed
+16215 clubbing
+16216 clubhouse
+16221 clump
+16222 clumsily
+16223 clumsy
+16224 clunky
+16225 clustered
+16226 clutch
+16231 clutter
+16232 coach
+16233 coagulant
+16234 coastal
+16235 coaster
+16236 coasting
+16241 coastland
+16242 coastline
+16243 coat
+16244 coauthor
+16245 cobalt
+16246 cobbler
+16251 cobweb
+16252 cocoa
+16253 coconut
+16254 cod
+16255 coeditor
+16256 coerce
+16261 coexist
+16262 coffee
+16263 cofounder
+16264 cognition
+16265 cognitive
+16266 cogwheel
+16311 coherence
+16312 coherent
+16313 cohesive
+16314 coil
+16315 coke
+16316 cola
+16321 cold
+16322 coleslaw
+16323 coliseum
+16324 collage
+16325 collapse
+16326 collar
+16331 collected
+16332 collector
+16333 collide
+16334 collie
+16335 collision
+16336 colonial
+16341 colonist
+16342 colonize
+16343 colony
+16344 colossal
+16345 colt
+16346 coma
+16351 come
+16352 comfort
+16353 comfy
+16354 comic
+16355 coming
+16356 comma
+16361 commence
+16362 commend
+16363 comment
+16364 commerce
+16365 commode
+16366 commodity
+16411 commodore
+16412 common
+16413 commotion
+16414 commute
+16415 commuting
+16416 compacted
+16421 compacter
+16422 compactly
+16423 compactor
+16424 companion
+16425 company
+16426 compare
+16431 compel
+16432 compile
+16433 comply
+16434 component
+16435 composed
+16436 composer
+16441 composite
+16442 compost
+16443 composure
+16444 compound
+16445 compress
+16446 comprised
+16451 computer
+16452 computing
+16453 comrade
+16454 concave
+16455 conceal
+16456 conceded
+16461 concept
+16462 concerned
+16463 concert
+16464 conch
+16465 concierge
+16466 concise
+16511 conclude
+16512 concrete
+16513 concur
+16514 condense
+16515 condiment
+16516 condition
+16521 condone
+16522 conducive
+16523 conductor
+16524 conduit
+16525 cone
+16526 confess
+16531 confetti
+16532 confidant
+16533 confident
+16534 confider
+16535 confiding
+16536 configure
+16541 confined
+16542 confining
+16543 confirm
+16544 conflict
+16545 conform
+16546 confound
+16551 confront
+16552 confused
+16553 confusing
+16554 confusion
+16555 congenial
+16556 congested
+16561 congrats
+16562 congress
+16563 conical
+16564 conjoined
+16565 conjure
+16566 conjuror
+16611 connected
+16612 connector
+16613 consensus
+16614 consent
+16615 console
+16616 consoling
+16621 consonant
+16622 constable
+16623 constant
+16624 constrain
+16625 constrict
+16626 construct
+16631 consult
+16632 consumer
+16633 consuming
+16634 contact
+16635 container
+16636 contempt
+16641 contend
+16642 contented
+16643 contently
+16644 contents
+16645 contest
+16646 context
+16651 contort
+16652 contour
+16653 contrite
+16654 control
+16655 contusion
+16656 convene
+16661 convent
+16662 copartner
+16663 cope
+16664 copied
+16665 copier
+16666 copilot
+21111 coping
+21112 copious
+21113 copper
+21114 copy
+21115 coral
+21116 cork
+21121 cornball
+21122 cornbread
+21123 corncob
+21124 cornea
+21125 corned
+21126 corner
+21131 cornfield
+21132 cornflake
+21133 cornhusk
+21134 cornmeal
+21135 cornstalk
+21136 corny
+21141 coronary
+21142 coroner
+21143 corporal
+21144 corporate
+21145 corral
+21146 correct
+21151 corridor
+21152 corrode
+21153 corroding
+21154 corrosive
+21155 corsage
+21156 corset
+21161 cortex
+21162 cosigner
+21163 cosmetics
+21164 cosmic
+21165 cosmos
+21166 cosponsor
+21211 cost
+21212 cottage
+21213 cotton
+21214 couch
+21215 cough
+21216 could
+21221 countable
+21222 countdown
+21223 counting
+21224 countless
+21225 country
+21226 county
+21231 courier
+21232 covenant
+21233 cover
+21234 coveted
+21235 coveting
+21236 coyness
+21241 cozily
+21242 coziness
+21243 cozy
+21244 crabbing
+21245 crabgrass
+21246 crablike
+21251 crabmeat
+21252 cradle
+21253 cradling
+21254 crafter
+21255 craftily
+21256 craftsman
+21261 craftwork
+21262 crafty
+21263 cramp
+21264 cranberry
+21265 crane
+21266 cranial
+21311 cranium
+21312 crank
+21313 crate
+21314 crave
+21315 craving
+21316 crawfish
+21321 crawlers
+21322 crawling
+21323 crayfish
+21324 crayon
+21325 crazed
+21326 crazily
+21331 craziness
+21332 crazy
+21333 creamed
+21334 creamer
+21335 creamlike
+21336 crease
+21341 creasing
+21342 creatable
+21343 create
+21344 creation
+21345 creative
+21346 creature
+21351 credible
+21352 credibly
+21353 credit
+21354 creed
+21355 creme
+21356 creole
+21361 crepe
+21362 crept
+21363 crescent
+21364 crested
+21365 cresting
+21366 crestless
+21411 crevice
+21412 crewless
+21413 crewman
+21414 crewmate
+21415 crib
+21416 cricket
+21421 cried
+21422 crier
+21423 crimp
+21424 crimson
+21425 cringe
+21426 cringing
+21431 crinkle
+21432 crinkly
+21433 crisped
+21434 crisping
+21435 crisply
+21436 crispness
+21441 crispy
+21442 criteria
+21443 critter
+21444 croak
+21445 crock
+21446 crook
+21451 croon
+21452 crop
+21453 cross
+21454 crouch
+21455 crouton
+21456 crowbar
+21461 crowd
+21462 crown
+21463 crucial
+21464 crudely
+21465 crudeness
+21466 cruelly
+21511 cruelness
+21512 cruelty
+21513 crumb
+21514 crummiest
+21515 crummy
+21516 crumpet
+21521 crumpled
+21522 cruncher
+21523 crunching
+21524 crunchy
+21525 crusader
+21526 crushable
+21531 crushed
+21532 crusher
+21533 crushing
+21534 crust
+21535 crux
+21536 crying
+21541 cryptic
+21542 crystal
+21543 cubbyhole
+21544 cube
+21545 cubical
+21546 cubicle
+21551 cucumber
+21552 cuddle
+21553 cuddly
+21554 cufflink
+21555 culinary
+21556 culminate
+21561 culpable
+21562 culprit
+21563 cultivate
+21564 cultural
+21565 culture
+21566 cupbearer
+21611 cupcake
+21612 cupid
+21613 cupped
+21614 cupping
+21615 curable
+21616 curator
+21621 curdle
+21622 cure
+21623 curfew
+21624 curing
+21625 curled
+21626 curler
+21631 curliness
+21632 curling
+21633 curly
+21634 curry
+21635 curse
+21636 cursive
+21641 cursor
+21642 curtain
+21643 curtly
+21644 curtsy
+21645 curvature
+21646 curve
+21651 curvy
+21652 cushy
+21653 cusp
+21654 cussed
+21655 custard
+21656 custodian
+21661 custody
+21662 customary
+21663 customer
+21664 customize
+21665 customs
+21666 cut
+22111 cycle
+22112 cyclic
+22113 cycling
+22114 cyclist
+22115 cylinder
+22116 cymbal
+22121 cytoplasm
+22122 cytoplast
+22123 dab
+22124 dad
+22125 daffodil
+22126 dagger
+22131 daily
+22132 daintily
+22133 dainty
+22134 dairy
+22135 daisy
+22136 dallying
+22141 dance
+22142 dancing
+22143 dandelion
+22144 dander
+22145 dandruff
+22146 dandy
+22151 danger
+22152 dangle
+22153 dangling
+22154 daredevil
+22155 dares
+22156 daringly
+22161 darkened
+22162 darkening
+22163 darkish
+22164 darkness
+22165 darkroom
+22166 darling
+22211 darn
+22212 dart
+22213 darwinism
+22214 dash
+22215 dastardly
+22216 data
+22221 datebook
+22222 dating
+22223 daughter
+22224 daunting
+22225 dawdler
+22226 dawn
+22231 daybed
+22232 daybreak
+22233 daycare
+22234 daydream
+22235 daylight
+22236 daylong
+22241 dayroom
+22242 daytime
+22243 dazzler
+22244 dazzling
+22245 deacon
+22246 deafening
+22251 deafness
+22252 dealer
+22253 dealing
+22254 dealmaker
+22255 dealt
+22256 dean
+22261 debatable
+22262 debate
+22263 debating
+22264 debit
+22265 debrief
+22266 debtless
+22311 debtor
+22312 debug
+22313 debunk
+22314 decade
+22315 decaf
+22316 decal
+22321 decathlon
+22322 decay
+22323 deceased
+22324 deceit
+22325 deceiver
+22326 deceiving
+22331 december
+22332 decency
+22333 decent
+22334 deception
+22335 deceptive
+22336 decibel
+22341 decidable
+22342 decimal
+22343 decimeter
+22344 decipher
+22345 deck
+22346 declared
+22351 decline
+22352 decode
+22353 decompose
+22354 decorated
+22355 decorator
+22356 decoy
+22361 decrease
+22362 decree
+22363 dedicate
+22364 dedicator
+22365 deduce
+22366 deduct
+22411 deed
+22412 deem
+22413 deepen
+22414 deeply
+22415 deepness
+22416 deface
+22421 defacing
+22422 defame
+22423 default
+22424 defeat
+22425 defection
+22426 defective
+22431 defendant
+22432 defender
+22433 defense
+22434 defensive
+22435 deferral
+22436 deferred
+22441 defiance
+22442 defiant
+22443 defile
+22444 defiling
+22445 define
+22446 definite
+22451 deflate
+22452 deflation
+22453 deflator
+22454 deflected
+22455 deflector
+22456 defog
+22461 deforest
+22462 defraud
+22463 defrost
+22464 deftly
+22465 defuse
+22466 defy
+22511 degraded
+22512 degrading
+22513 degrease
+22514 degree
+22515 dehydrate
+22516 deity
+22521 dejected
+22522 delay
+22523 delegate
+22524 delegator
+22525 delete
+22526 deletion
+22531 delicacy
+22532 delicate
+22533 delicious
+22534 delighted
+22535 delirious
+22536 delirium
+22541 deliverer
+22542 delivery
+22543 delouse
+22544 delta
+22545 deluge
+22546 delusion
+22551 deluxe
+22552 demanding
+22553 demeaning
+22554 demeanor
+22555 demise
+22556 democracy
+22561 democrat
+22562 demote
+22563 demotion
+22564 demystify
+22565 denatured
+22566 deniable
+22611 denial
+22612 denim
+22613 denote
+22614 dense
+22615 density
+22616 dental
+22621 dentist
+22622 denture
+22623 deny
+22624 deodorant
+22625 deodorize
+22626 departed
+22631 departure
+22632 depict
+22633 deplete
+22634 depletion
+22635 deplored
+22636 deploy
+22641 deport
+22642 depose
+22643 depraved
+22644 depravity
+22645 deprecate
+22646 depress
+22651 deprive
+22652 depth
+22653 deputize
+22654 deputy
+22655 derail
+22656 deranged
+22661 derby
+22662 derived
+22663 desecrate
+22664 deserve
+22665 deserving
+22666 designate
+23111 designed
+23112 designer
+23113 designing
+23114 deskbound
+23115 desktop
+23116 deskwork
+23121 desolate
+23122 despair
+23123 despise
+23124 despite
+23125 destiny
+23126 destitute
+23131 destruct
+23132 detached
+23133 detail
+23134 detection
+23135 detective
+23136 detector
+23141 detention
+23142 detergent
+23143 detest
+23144 detonate
+23145 detonator
+23146 detoxify
+23151 detract
+23152 deuce
+23153 devalue
+23154 deviancy
+23155 deviant
+23156 deviate
+23161 deviation
+23162 deviator
+23163 device
+23164 devious
+23165 devotedly
+23166 devotee
+23211 devotion
+23212 devourer
+23213 devouring
+23214 devoutly
+23215 dexterity
+23216 dexterous
+23221 diabetes
+23222 diabetic
+23223 diabolic
+23224 diagnoses
+23225 diagnosis
+23226 diagram
+23231 dial
+23232 diameter
+23233 diaper
+23234 diaphragm
+23235 diary
+23236 dice
+23241 dicing
+23242 dictate
+23243 dictation
+23244 dictator
+23245 difficult
+23246 diffused
+23251 diffuser
+23252 diffusion
+23253 diffusive
+23254 dig
+23255 dilation
+23256 diligence
+23261 diligent
+23262 dill
+23263 dilute
+23264 dime
+23265 diminish
+23266 dimly
+23311 dimmed
+23312 dimmer
+23313 dimness
+23314 dimple
+23315 diner
+23316 dingbat
+23321 dinghy
+23322 dinginess
+23323 dingo
+23324 dingy
+23325 dining
+23326 dinner
+23331 diocese
+23332 dioxide
+23333 diploma
+23334 dipped
+23335 dipper
+23336 dipping
+23341 directed
+23342 direction
+23343 directive
+23344 directly
+23345 directory
+23346 direness
+23351 dirtiness
+23352 disabled
+23353 disagree
+23354 disallow
+23355 disarm
+23356 disarray
+23361 disaster
+23362 disband
+23363 disbelief
+23364 disburse
+23365 discard
+23366 discern
+23411 discharge
+23412 disclose
+23413 discolor
+23414 discount
+23415 discourse
+23416 discover
+23421 discuss
+23422 disdain
+23423 disengage
+23424 disfigure
+23425 disgrace
+23426 dish
+23431 disinfect
+23432 disjoin
+23433 disk
+23434 dislike
+23435 disliking
+23436 dislocate
+23441 dislodge
+23442 disloyal
+23443 dismantle
+23444 dismay
+23445 dismiss
+23446 dismount
+23451 disobey
+23452 disorder
+23453 disown
+23454 disparate
+23455 disparity
+23456 dispatch
+23461 dispense
+23462 dispersal
+23463 dispersed
+23464 disperser
+23465 displace
+23466 display
+23511 displease
+23512 disposal
+23513 dispose
+23514 disprove
+23515 dispute
+23516 disregard
+23521 disrupt
+23522 dissuade
+23523 distance
+23524 distant
+23525 distaste
+23526 distill
+23531 distinct
+23532 distort
+23533 distract
+23534 distress
+23535 district
+23536 distrust
+23541 ditch
+23542 ditto
+23543 ditzy
+23544 dividable
+23545 divided
+23546 dividend
+23551 dividers
+23552 dividing
+23553 divinely
+23554 diving
+23555 divinity
+23556 divisible
+23561 divisibly
+23562 division
+23563 divisive
+23564 divorcee
+23565 dizziness
+23566 dizzy
+23611 doable
+23612 docile
+23613 dock
+23614 doctrine
+23615 document
+23616 dodge
+23621 dodgy
+23622 doily
+23623 doing
+23624 dole
+23625 dollar
+23626 dollhouse
+23631 dollop
+23632 dolly
+23633 dolphin
+23634 domain
+23635 domelike
+23636 domestic
+23641 dominion
+23642 dominoes
+23643 donated
+23644 donation
+23645 donator
+23646 donor
+23651 donut
+23652 doodle
+23653 doorbell
+23654 doorframe
+23655 doorknob
+23656 doorman
+23661 doormat
+23662 doornail
+23663 doorpost
+23664 doorstep
+23665 doorstop
+23666 doorway
+24111 doozy
+24112 dork
+24113 dormitory
+24114 dorsal
+24115 dosage
+24116 dose
+24121 dotted
+24122 doubling
+24123 douche
+24124 dove
+24125 down
+24126 dowry
+24131 doze
+24132 drab
+24133 dragging
+24134 dragonfly
+24135 dragonish
+24136 dragster
+24141 drainable
+24142 drainage
+24143 drained
+24144 drainer
+24145 drainpipe
+24146 dramatic
+24151 dramatize
+24152 drank
+24153 drapery
+24154 drastic
+24155 draw
+24156 dreaded
+24161 dreadful
+24162 dreadlock
+24163 dreamboat
+24164 dreamily
+24165 dreamland
+24166 dreamless
+24211 dreamlike
+24212 dreamt
+24213 dreamy
+24214 drearily
+24215 dreary
+24216 drench
+24221 dress
+24222 drew
+24223 dribble
+24224 dried
+24225 drier
+24226 drift
+24231 driller
+24232 drilling
+24233 drinkable
+24234 drinking
+24235 dripping
+24236 drippy
+24241 drivable
+24242 driven
+24243 driver
+24244 driveway
+24245 driving
+24246 drizzle
+24251 drizzly
+24252 drone
+24253 drool
+24254 droop
+24255 drop-down
+24256 dropbox
+24261 dropkick
+24262 droplet
+24263 dropout
+24264 dropper
+24265 drove
+24266 drown
+24311 drowsily
+24312 drudge
+24313 drum
+24314 dry
+24315 dubbed
+24316 dubiously
+24321 duchess
+24322 duckbill
+24323 ducking
+24324 duckling
+24325 ducktail
+24326 ducky
+24331 duct
+24332 dude
+24333 duffel
+24334 dugout
+24335 duh
+24336 duke
+24341 duller
+24342 dullness
+24343 duly
+24344 dumping
+24345 dumpling
+24346 dumpster
+24351 duo
+24352 dupe
+24353 duplex
+24354 duplicate
+24355 duplicity
+24356 durable
+24361 durably
+24362 duration
+24363 duress
+24364 during
+24365 dusk
+24366 dust
+24411 dutiful
+24412 duty
+24413 duvet
+24414 dwarf
+24415 dweeb
+24416 dwelled
+24421 dweller
+24422 dwelling
+24423 dwindle
+24424 dwindling
+24425 dynamic
+24426 dynamite
+24431 dynasty
+24432 dyslexia
+24433 dyslexic
+24434 each
+24435 eagle
+24436 earache
+24441 eardrum
+24442 earflap
+24443 earful
+24444 earlobe
+24445 early
+24446 earmark
+24451 earmuff
+24452 earphone
+24453 earpiece
+24454 earplugs
+24455 earring
+24456 earshot
+24461 earthen
+24462 earthlike
+24463 earthling
+24464 earthly
+24465 earthworm
+24466 earthy
+24511 earwig
+24512 easeful
+24513 easel
+24514 easiest
+24515 easily
+24516 easiness
+24521 easing
+24522 eastbound
+24523 eastcoast
+24524 easter
+24525 eastward
+24526 eatable
+24531 eaten
+24532 eatery
+24533 eating
+24534 eats
+24535 ebay
+24536 ebony
+24541 ebook
+24542 ecard
+24543 eccentric
+24544 echo
+24545 eclair
+24546 eclipse
+24551 ecologist
+24552 ecology
+24553 economic
+24554 economist
+24555 economy
+24556 ecosphere
+24561 ecosystem
+24562 edge
+24563 edginess
+24564 edging
+24565 edgy
+24566 edition
+24611 editor
+24612 educated
+24613 education
+24614 educator
+24615 eel
+24616 effective
+24621 effects
+24622 efficient
+24623 effort
+24624 eggbeater
+24625 egging
+24626 eggnog
+24631 eggplant
+24632 eggshell
+24633 egomaniac
+24634 egotism
+24635 egotistic
+24636 either
+24641 eject
+24642 elaborate
+24643 elastic
+24644 elated
+24645 elbow
+24646 eldercare
+24651 elderly
+24652 eldest
+24653 electable
+24654 election
+24655 elective
+24656 elephant
+24661 elevate
+24662 elevating
+24663 elevation
+24664 elevator
+24665 eleven
+24666 elf
+25111 eligible
+25112 eligibly
+25113 eliminate
+25114 elite
+25115 elitism
+25116 elixir
+25121 elk
+25122 ellipse
+25123 elliptic
+25124 elm
+25125 elongated
+25126 elope
+25131 eloquence
+25132 eloquent
+25133 elsewhere
+25134 elude
+25135 elusive
+25136 elves
+25141 email
+25142 embargo
+25143 embark
+25144 embassy
+25145 embattled
+25146 embellish
+25151 ember
+25152 embezzle
+25153 emblaze
+25154 emblem
+25155 embody
+25156 embolism
+25161 emboss
+25162 embroider
+25163 emcee
+25164 emerald
+25165 emergency
+25166 emission
+25211 emit
+25212 emote
+25213 emoticon
+25214 emotion
+25215 empathic
+25216 empathy
+25221 emperor
+25222 emphases
+25223 emphasis
+25224 emphasize
+25225 emphatic
+25226 empirical
+25231 employed
+25232 employee
+25233 employer
+25234 emporium
+25235 empower
+25236 emptier
+25241 emptiness
+25242 empty
+25243 emu
+25244 enable
+25245 enactment
+25246 enamel
+25251 enchanted
+25252 enchilada
+25253 encircle
+25254 enclose
+25255 enclosure
+25256 encode
+25261 encore
+25262 encounter
+25263 encourage
+25264 encroach
+25265 encrust
+25266 encrypt
+25311 endanger
+25312 endeared
+25313 endearing
+25314 ended
+25315 ending
+25316 endless
+25321 endnote
+25322 endocrine
+25323 endorphin
+25324 endorse
+25325 endowment
+25326 endpoint
+25331 endurable
+25332 endurance
+25333 enduring
+25334 energetic
+25335 energize
+25336 energy
+25341 enforced
+25342 enforcer
+25343 engaged
+25344 engaging
+25345 engine
+25346 engorge
+25351 engraved
+25352 engraver
+25353 engraving
+25354 engross
+25355 engulf
+25356 enhance
+25361 enigmatic
+25362 enjoyable
+25363 enjoyably
+25364 enjoyer
+25365 enjoying
+25366 enjoyment
+25411 enlarged
+25412 enlarging
+25413 enlighten
+25414 enlisted
+25415 enquirer
+25416 enrage
+25421 enrich
+25422 enroll
+25423 enslave
+25424 ensnare
+25425 ensure
+25426 entail
+25431 entangled
+25432 entering
+25433 entertain
+25434 enticing
+25435 entire
+25436 entitle
+25441 entity
+25442 entomb
+25443 entourage
+25444 entrap
+25445 entree
+25446 entrench
+25451 entrust
+25452 entryway
+25453 entwine
+25454 enunciate
+25455 envelope
+25456 enviable
+25461 enviably
+25462 envious
+25463 envision
+25464 envoy
+25465 envy
+25466 enzyme
+25511 epic
+25512 epidemic
+25513 epidermal
+25514 epidermis
+25515 epidural
+25516 epilepsy
+25521 epileptic
+25522 epilogue
+25523 epiphany
+25524 episode
+25525 equal
+25526 equate
+25531 equation
+25532 equator
+25533 equinox
+25534 equipment
+25535 equity
+25536 equivocal
+25541 eradicate
+25542 erasable
+25543 erased
+25544 eraser
+25545 erasure
+25546 ergonomic
+25551 errand
+25552 errant
+25553 erratic
+25554 error
+25555 erupt
+25556 escalate
+25561 escalator
+25562 escapable
+25563 escapade
+25564 escapist
+25565 escargot
+25566 eskimo
+25611 esophagus
+25612 espionage
+25613 espresso
+25614 esquire
+25615 essay
+25616 essence
+25621 essential
+25622 establish
+25623 estate
+25624 esteemed
+25625 estimate
+25626 estimator
+25631 estranged
+25632 estrogen
+25633 etching
+25634 eternal
+25635 eternity
+25636 ethanol
+25641 ether
+25642 ethically
+25643 ethics
+25644 euphemism
+25645 evacuate
+25646 evacuee
+25651 evade
+25652 evaluate
+25653 evaluator
+25654 evaporate
+25655 evasion
+25656 evasive
+25661 even
+25662 everglade
+25663 evergreen
+25664 everybody
+25665 everyday
+25666 everyone
+26111 evict
+26112 evidence
+26113 evident
+26114 evil
+26115 evoke
+26116 evolution
+26121 evolve
+26122 exact
+26123 exalted
+26124 example
+26125 excavate
+26126 excavator
+26131 exceeding
+26132 exception
+26133 excess
+26134 exchange
+26135 excitable
+26136 exciting
+26141 exclaim
+26142 exclude
+26143 excluding
+26144 exclusion
+26145 exclusive
+26146 excretion
+26151 excretory
+26152 excursion
+26153 excusable
+26154 excusably
+26155 excuse
+26156 exemplary
+26161 exemplify
+26162 exemption
+26163 exerciser
+26164 exert
+26165 exes
+26166 exfoliate
+26211 exhale
+26212 exhaust
+26213 exhume
+26214 exile
+26215 existing
+26216 exit
+26221 exodus
+26222 exonerate
+26223 exorcism
+26224 exorcist
+26225 expand
+26226 expanse
+26231 expansion
+26232 expansive
+26233 expectant
+26234 expedited
+26235 expediter
+26236 expel
+26241 expend
+26242 expenses
+26243 expensive
+26244 expert
+26245 expire
+26246 expiring
+26251 explain
+26252 expletive
+26253 explicit
+26254 explode
+26255 exploit
+26256 explore
+26261 exploring
+26262 exponent
+26263 exporter
+26264 exposable
+26265 expose
+26266 exposure
+26311 express
+26312 expulsion
+26313 exquisite
+26314 extended
+26315 extending
+26316 extent
+26321 extenuate
+26322 exterior
+26323 external
+26324 extinct
+26325 extortion
+26326 extradite
+26331 extras
+26332 extrovert
+26333 extrude
+26334 extruding
+26335 exuberant
+26336 fable
+26341 fabric
+26342 fabulous
+26343 facebook
+26344 facecloth
+26345 facedown
+26346 faceless
+26351 facelift
+26352 faceplate
+26353 faceted
+26354 facial
+26355 facility
+26356 facing
+26361 facsimile
+26362 faction
+26363 factoid
+26364 factor
+26365 factsheet
+26366 factual
+26411 faculty
+26412 fade
+26413 fading
+26414 failing
+26415 falcon
+26416 fall
+26421 false
+26422 falsify
+26423 fame
+26424 familiar
+26425 family
+26426 famine
+26431 famished
+26432 fanatic
+26433 fancied
+26434 fanciness
+26435 fancy
+26436 fanfare
+26441 fang
+26442 fanning
+26443 fantasize
+26444 fantastic
+26445 fantasy
+26446 fascism
+26451 fastball
+26452 faster
+26453 fasting
+26454 fastness
+26455 faucet
+26456 favorable
+26461 favorably
+26462 favored
+26463 favoring
+26464 favorite
+26465 fax
+26466 feast
+26511 federal
+26512 fedora
+26513 feeble
+26514 feed
+26515 feel
+26516 feisty
+26521 feline
+26522 felt-tip
+26523 feminine
+26524 feminism
+26525 feminist
+26526 feminize
+26531 femur
+26532 fence
+26533 fencing
+26534 fender
+26535 ferment
+26536 fernlike
+26541 ferocious
+26542 ferocity
+26543 ferret
+26544 ferris
+26545 ferry
+26546 fervor
+26551 fester
+26552 festival
+26553 festive
+26554 festivity
+26555 fetal
+26556 fetch
+26561 fever
+26562 fiber
+26563 fiction
+26564 fiddle
+26565 fiddling
+26566 fidelity
+26611 fidgeting
+26612 fidgety
+26613 fifteen
+26614 fifth
+26615 fiftieth
+26616 fifty
+26621 figment
+26622 figure
+26623 figurine
+26624 filing
+26625 filled
+26626 filler
+26631 filling
+26632 film
+26633 filter
+26634 filth
+26635 filtrate
+26636 finale
+26641 finalist
+26642 finalize
+26643 finally
+26644 finance
+26645 financial
+26646 finch
+26651 fineness
+26652 finer
+26653 finicky
+26654 finished
+26655 finisher
+26656 finishing
+26661 finite
+26662 finless
+26663 finlike
+26664 fiscally
+26665 fit
+26666 five
+31111 flaccid
+31112 flagman
+31113 flagpole
+31114 flagship
+31115 flagstick
+31116 flagstone
+31121 flail
+31122 flakily
+31123 flaky
+31124 flame
+31125 flammable
+31126 flanked
+31131 flanking
+31132 flannels
+31133 flap
+31134 flaring
+31135 flashback
+31136 flashbulb
+31141 flashcard
+31142 flashily
+31143 flashing
+31144 flashy
+31145 flask
+31146 flatbed
+31151 flatfoot
+31152 flatly
+31153 flatness
+31154 flatten
+31155 flattered
+31156 flatterer
+31161 flattery
+31162 flattop
+31163 flatware
+31164 flatworm
+31165 flavored
+31166 flavorful
+31211 flavoring
+31212 flaxseed
+31213 fled
+31214 fleshed
+31215 fleshy
+31216 flick
+31221 flier
+31222 flight
+31223 flinch
+31224 fling
+31225 flint
+31226 flip
+31231 flirt
+31232 float
+31233 flock
+31234 flogging
+31235 flop
+31236 floral
+31241 florist
+31242 floss
+31243 flounder
+31244 flyable
+31245 flyaway
+31246 flyer
+31251 flying
+31252 flyover
+31253 flypaper
+31254 foam
+31255 foe
+31256 fog
+31261 foil
+31262 folic
+31263 folk
+31264 follicle
+31265 follow
+31266 fondling
+31311 fondly
+31312 fondness
+31313 fondue
+31314 font
+31315 food
+31316 fool
+31321 footage
+31322 football
+31323 footbath
+31324 footboard
+31325 footer
+31326 footgear
+31331 foothill
+31332 foothold
+31333 footing
+31334 footless
+31335 footman
+31336 footnote
+31341 footpad
+31342 footpath
+31343 footprint
+31344 footrest
+31345 footsie
+31346 footsore
+31351 footwear
+31352 footwork
+31353 fossil
+31354 foster
+31355 founder
+31356 founding
+31361 fountain
+31362 fox
+31363 foyer
+31364 fraction
+31365 fracture
+31366 fragile
+31411 fragility
+31412 fragment
+31413 fragrance
+31414 fragrant
+31415 frail
+31416 frame
+31421 framing
+31422 frantic
+31423 fraternal
+31424 frayed
+31425 fraying
+31426 frays
+31431 freckled
+31432 freckles
+31433 freebase
+31434 freebee
+31435 freebie
+31436 freedom
+31441 freefall
+31442 freehand
+31443 freeing
+31444 freeload
+31445 freely
+31446 freemason
+31451 freeness
+31452 freestyle
+31453 freeware
+31454 freeway
+31455 freewill
+31456 freezable
+31461 freezing
+31462 freight
+31463 french
+31464 frenzied
+31465 frenzy
+31466 frequency
+31511 frequent
+31512 fresh
+31513 fretful
+31514 fretted
+31515 friction
+31516 friday
+31521 fridge
+31522 fried
+31523 friend
+31524 frighten
+31525 frightful
+31526 frigidity
+31531 frigidly
+31532 frill
+31533 fringe
+31534 frisbee
+31535 frisk
+31536 fritter
+31541 frivolous
+31542 frolic
+31543 from
+31544 front
+31545 frostbite
+31546 frosted
+31551 frostily
+31552 frosting
+31553 frostlike
+31554 frosty
+31555 froth
+31556 frown
+31561 frozen
+31562 fructose
+31563 frugality
+31564 frugally
+31565 fruit
+31566 frustrate
+31611 frying
+31612 gab
+31613 gaffe
+31614 gag
+31615 gainfully
+31616 gaining
+31621 gains
+31622 gala
+31623 gallantly
+31624 galleria
+31625 gallery
+31626 galley
+31631 gallon
+31632 gallows
+31633 gallstone
+31634 galore
+31635 galvanize
+31636 gambling
+31641 game
+31642 gaming
+31643 gamma
+31644 gander
+31645 gangly
+31646 gangrene
+31651 gangway
+31652 gap
+31653 garage
+31654 garbage
+31655 garden
+31656 gargle
+31661 garland
+31662 garlic
+31663 garment
+31664 garnet
+31665 garnish
+31666 garter
+32111 gas
+32112 gatherer
+32113 gathering
+32114 gating
+32115 gauging
+32116 gauntlet
+32121 gauze
+32122 gave
+32123 gawk
+32124 gazing
+32125 gear
+32126 gecko
+32131 geek
+32132 geiger
+32133 gem
+32134 gender
+32135 generic
+32136 generous
+32141 genetics
+32142 genre
+32143 gentile
+32144 gentleman
+32145 gently
+32146 gents
+32151 geography
+32152 geologic
+32153 geologist
+32154 geology
+32155 geometric
+32156 geometry
+32161 geranium
+32162 gerbil
+32163 geriatric
+32164 germicide
+32165 germinate
+32166 germless
+32211 germproof
+32212 gestate
+32213 gestation
+32214 gesture
+32215 getaway
+32216 getting
+32221 getup
+32222 giant
+32223 gibberish
+32224 giblet
+32225 giddily
+32226 giddiness
+32231 giddy
+32232 gift
+32233 gigabyte
+32234 gigahertz
+32235 gigantic
+32236 giggle
+32241 giggling
+32242 giggly
+32243 gigolo
+32244 gilled
+32245 gills
+32246 gimmick
+32251 girdle
+32252 giveaway
+32253 given
+32254 giver
+32255 giving
+32256 gizmo
+32261 gizzard
+32262 glacial
+32263 glacier
+32264 glade
+32265 gladiator
+32266 gladly
+32311 glamorous
+32312 glamour
+32313 glance
+32314 glancing
+32315 glandular
+32316 glare
+32321 glaring
+32322 glass
+32323 glaucoma
+32324 glazing
+32325 gleaming
+32326 gleeful
+32331 glider
+32332 gliding
+32333 glimmer
+32334 glimpse
+32335 glisten
+32336 glitch
+32341 glitter
+32342 glitzy
+32343 gloater
+32344 gloating
+32345 gloomily
+32346 gloomy
+32351 glorified
+32352 glorifier
+32353 glorify
+32354 glorious
+32355 glory
+32356 gloss
+32361 glove
+32362 glowing
+32363 glowworm
+32364 glucose
+32365 glue
+32366 gluten
+32411 glutinous
+32412 glutton
+32413 gnarly
+32414 gnat
+32415 goal
+32416 goatskin
+32421 goes
+32422 goggles
+32423 going
+32424 goldfish
+32425 goldmine
+32426 goldsmith
+32431 golf
+32432 goliath
+32433 gonad
+32434 gondola
+32435 gone
+32436 gong
+32441 good
+32442 gooey
+32443 goofball
+32444 goofiness
+32445 goofy
+32446 google
+32451 goon
+32452 gopher
+32453 gore
+32454 gorged
+32455 gorgeous
+32456 gory
+32461 gosling
+32462 gossip
+32463 gothic
+32464 gotten
+32465 gout
+32466 gown
+32511 grab
+32512 graceful
+32513 graceless
+32514 gracious
+32515 gradation
+32516 graded
+32521 grader
+32522 gradient
+32523 grading
+32524 gradually
+32525 graduate
+32526 graffiti
+32531 grafted
+32532 grafting
+32533 grain
+32534 granddad
+32535 grandkid
+32536 grandly
+32541 grandma
+32542 grandpa
+32543 grandson
+32544 granite
+32545 granny
+32546 granola
+32551 grant
+32552 granular
+32553 grape
+32554 graph
+32555 grapple
+32556 grappling
+32561 grasp
+32562 grass
+32563 gratified
+32564 gratify
+32565 grating
+32566 gratitude
+32611 gratuity
+32612 gravel
+32613 graveness
+32614 graves
+32615 graveyard
+32616 gravitate
+32621 gravity
+32622 gravy
+32623 gray
+32624 grazing
+32625 greasily
+32626 greedily
+32631 greedless
+32632 greedy
+32633 green
+32634 greeter
+32635 greeting
+32636 grew
+32641 greyhound
+32642 grid
+32643 grief
+32644 grievance
+32645 grieving
+32646 grievous
+32651 grill
+32652 grimace
+32653 grimacing
+32654 grime
+32655 griminess
+32656 grimy
+32661 grinch
+32662 grinning
+32663 grip
+32664 gristle
+32665 grit
+32666 groggily
+33111 groggy
+33112 groin
+33113 groom
+33114 groove
+33115 grooving
+33116 groovy
+33121 grope
+33122 ground
+33123 grouped
+33124 grout
+33125 grove
+33126 grower
+33131 growing
+33132 growl
+33133 grub
+33134 grudge
+33135 grudging
+33136 grueling
+33141 gruffly
+33142 grumble
+33143 grumbling
+33144 grumbly
+33145 grumpily
+33146 grunge
+33151 grunt
+33152 guacamole
+33153 guidable
+33154 guidance
+33155 guide
+33156 guiding
+33161 guileless
+33162 guise
+33163 gulf
+33164 gullible
+33165 gully
+33166 gulp
+33211 gumball
+33212 gumdrop
+33213 gumminess
+33214 gumming
+33215 gummy
+33216 gurgle
+33221 gurgling
+33222 guru
+33223 gush
+33224 gusto
+33225 gusty
+33226 gutless
+33231 guts
+33232 gutter
+33233 guy
+33234 guzzler
+33235 gyration
+33236 habitable
+33241 habitant
+33242 habitat
+33243 habitual
+33244 hacked
+33245 hacker
+33246 hacking
+33251 hacksaw
+33252 had
+33253 haggler
+33254 haiku
+33255 half
+33256 halogen
+33261 halt
+33262 halved
+33263 halves
+33264 hamburger
+33265 hamlet
+33266 hammock
+33311 hamper
+33312 hamster
+33313 hamstring
+33314 handbag
+33315 handball
+33316 handbook
+33321 handbrake
+33322 handcart
+33323 handclap
+33324 handclasp
+33325 handcraft
+33326 handcuff
+33331 handed
+33332 handful
+33333 handgrip
+33334 handgun
+33335 handheld
+33336 handiness
+33341 handiwork
+33342 handlebar
+33343 handled
+33344 handler
+33345 handling
+33346 handmade
+33351 handoff
+33352 handpick
+33353 handprint
+33354 handrail
+33355 handsaw
+33356 handset
+33361 handsfree
+33362 handshake
+33363 handstand
+33364 handwash
+33365 handwork
+33366 handwoven
+33411 handwrite
+33412 handyman
+33413 hangnail
+33414 hangout
+33415 hangover
+33416 hangup
+33421 hankering
+33422 hankie
+33423 hanky
+33424 haphazard
+33425 happening
+33426 happier
+33431 happiest
+33432 happily
+33433 happiness
+33434 happy
+33435 harbor
+33436 hardcopy
+33441 hardcore
+33442 hardcover
+33443 harddisk
+33444 hardened
+33445 hardener
+33446 hardening
+33451 hardhat
+33452 hardhead
+33453 hardiness
+33454 hardly
+33455 hardness
+33456 hardship
+33461 hardware
+33462 hardwired
+33463 hardwood
+33464 hardy
+33465 harmful
+33466 harmless
+33511 harmonica
+33512 harmonics
+33513 harmonize
+33514 harmony
+33515 harness
+33516 harpist
+33521 harsh
+33522 harvest
+33523 hash
+33524 hassle
+33525 haste
+33526 hastily
+33531 hastiness
+33532 hasty
+33533 hatbox
+33534 hatchback
+33535 hatchery
+33536 hatchet
+33541 hatching
+33542 hatchling
+33543 hate
+33544 hatless
+33545 hatred
+33546 haunt
+33551 haven
+33552 hazard
+33553 hazelnut
+33554 hazily
+33555 haziness
+33556 hazing
+33561 hazy
+33562 headache
+33563 headband
+33564 headboard
+33565 headcount
+33566 headdress
+33611 headed
+33612 header
+33613 headfirst
+33614 headgear
+33615 heading
+33616 headlamp
+33621 headless
+33622 headlock
+33623 headphone
+33624 headpiece
+33625 headrest
+33626 headroom
+33631 headscarf
+33632 headset
+33633 headsman
+33634 headstand
+33635 headstone
+33636 headway
+33641 headwear
+33642 heap
+33643 heat
+33644 heave
+33645 heavily
+33646 heaviness
+33651 heaving
+33652 hedge
+33653 hedging
+33654 heftiness
+33655 hefty
+33656 helium
+33661 helmet
+33662 helper
+33663 helpful
+33664 helping
+33665 helpless
+33666 helpline
+34111 hemlock
+34112 hemstitch
+34113 hence
+34114 henchman
+34115 henna
+34116 herald
+34121 herbal
+34122 herbicide
+34123 herbs
+34124 heritage
+34125 hermit
+34126 heroics
+34131 heroism
+34132 herring
+34133 herself
+34134 hertz
+34135 hesitancy
+34136 hesitant
+34141 hesitate
+34142 hexagon
+34143 hexagram
+34144 hubcap
+34145 huddle
+34146 huddling
+34151 huff
+34152 hug
+34153 hula
+34154 hulk
+34155 hull
+34156 human
+34161 humble
+34162 humbling
+34163 humbly
+34164 humid
+34165 humiliate
+34166 humility
+34211 humming
+34212 hummus
+34213 humongous
+34214 humorist
+34215 humorless
+34216 humorous
+34221 humpback
+34222 humped
+34223 humvee
+34224 hunchback
+34225 hundredth
+34226 hunger
+34231 hungrily
+34232 hungry
+34233 hunk
+34234 hunter
+34235 hunting
+34236 huntress
+34241 huntsman
+34242 hurdle
+34243 hurled
+34244 hurler
+34245 hurling
+34246 hurray
+34251 hurricane
+34252 hurried
+34253 hurry
+34254 hurt
+34255 husband
+34256 hush
+34261 husked
+34262 huskiness
+34263 hut
+34264 hybrid
+34265 hydrant
+34266 hydrated
+34311 hydration
+34312 hydrogen
+34313 hydroxide
+34314 hyperlink
+34315 hypertext
+34316 hyphen
+34321 hypnoses
+34322 hypnosis
+34323 hypnotic
+34324 hypnotism
+34325 hypnotist
+34326 hypnotize
+34331 hypocrisy
+34332 hypocrite
+34333 ibuprofen
+34334 ice
+34335 iciness
+34336 icing
+34341 icky
+34342 icon
+34343 icy
+34344 idealism
+34345 idealist
+34346 idealize
+34351 ideally
+34352 idealness
+34353 identical
+34354 identify
+34355 identity
+34356 ideology
+34361 idiocy
+34362 idiom
+34363 idly
+34364 igloo
+34365 ignition
+34366 ignore
+34411 iguana
+34412 illicitly
+34413 illusion
+34414 illusive
+34415 image
+34416 imaginary
+34421 imagines
+34422 imaging
+34423 imbecile
+34424 imitate
+34425 imitation
+34426 immature
+34431 immerse
+34432 immersion
+34433 imminent
+34434 immobile
+34435 immodest
+34436 immorally
+34441 immortal
+34442 immovable
+34443 immovably
+34444 immunity
+34445 immunize
+34446 impaired
+34451 impale
+34452 impart
+34453 impatient
+34454 impeach
+34455 impeding
+34456 impending
+34461 imperfect
+34462 imperial
+34463 impish
+34464 implant
+34465 implement
+34466 implicate
+34511 implicit
+34512 implode
+34513 implosion
+34514 implosive
+34515 imply
+34516 impolite
+34521 important
+34522 importer
+34523 impose
+34524 imposing
+34525 impotence
+34526 impotency
+34531 impotent
+34532 impound
+34533 imprecise
+34534 imprint
+34535 imprison
+34536 impromptu
+34541 improper
+34542 improve
+34543 improving
+34544 improvise
+34545 imprudent
+34546 impulse
+34551 impulsive
+34552 impure
+34553 impurity
+34554 iodine
+34555 iodize
+34556 ion
+34561 ipad
+34562 iphone
+34563 ipod
+34564 irate
+34565 irk
+34566 iron
+34611 irregular
+34612 irrigate
+34613 irritable
+34614 irritably
+34615 irritant
+34616 irritate
+34621 islamic
+34622 islamist
+34623 isolated
+34624 isolating
+34625 isolation
+34626 isotope
+34631 issue
+34632 issuing
+34633 italicize
+34634 italics
+34635 item
+34636 itinerary
+34641 itunes
+34642 ivory
+34643 ivy
+34644 jab
+34645 jackal
+34646 jacket
+34651 jackknife
+34652 jackpot
+34653 jailbird
+34654 jailbreak
+34655 jailer
+34656 jailhouse
+34661 jalapeno
+34662 jam
+34663 janitor
+34664 january
+34665 jargon
+34666 jarring
+35111 jasmine
+35112 jaundice
+35113 jaunt
+35114 java
+35115 jawed
+35116 jawless
+35121 jawline
+35122 jaws
+35123 jaybird
+35124 jaywalker
+35125 jazz
+35126 jeep
+35131 jeeringly
+35132 jellied
+35133 jelly
+35134 jersey
+35135 jester
+35136 jet
+35141 jiffy
+35142 jigsaw
+35143 jimmy
+35144 jingle
+35145 jingling
+35146 jinx
+35151 jitters
+35152 jittery
+35153 job
+35154 jockey
+35155 jockstrap
+35156 jogger
+35161 jogging
+35162 john
+35163 joining
+35164 jokester
+35165 jokingly
+35166 jolliness
+35211 jolly
+35212 jolt
+35213 jot
+35214 jovial
+35215 joyfully
+35216 joylessly
+35221 joyous
+35222 joyride
+35223 joystick
+35224 jubilance
+35225 jubilant
+35226 judge
+35231 judgingly
+35232 judicial
+35233 judiciary
+35234 judo
+35235 juggle
+35236 juggling
+35241 jugular
+35242 juice
+35243 juiciness
+35244 juicy
+35245 jujitsu
+35246 jukebox
+35251 july
+35252 jumble
+35253 jumbo
+35254 jump
+35255 junction
+35256 juncture
+35261 june
+35262 junior
+35263 juniper
+35264 junkie
+35265 junkman
+35266 junkyard
+35311 jurist
+35312 juror
+35313 jury
+35314 justice
+35315 justifier
+35316 justify
+35321 justly
+35322 justness
+35323 juvenile
+35324 kabob
+35325 kangaroo
+35326 karaoke
+35331 karate
+35332 karma
+35333 kebab
+35334 keenly
+35335 keenness
+35336 keep
+35341 keg
+35342 kelp
+35343 kennel
+35344 kept
+35345 kerchief
+35346 kerosene
+35351 kettle
+35352 kick
+35353 kiln
+35354 kilobyte
+35355 kilogram
+35356 kilometer
+35361 kilowatt
+35362 kilt
+35363 kimono
+35364 kindle
+35365 kindling
+35366 kindly
+35411 kindness
+35412 kindred
+35413 kinetic
+35414 kinfolk
+35415 king
+35416 kinship
+35421 kinsman
+35422 kinswoman
+35423 kissable
+35424 kisser
+35425 kissing
+35426 kitchen
+35431 kite
+35432 kitten
+35433 kitty
+35434 kiwi
+35435 kleenex
+35436 knapsack
+35441 knee
+35442 knelt
+35443 knickers
+35444 knoll
+35445 koala
+35446 kooky
+35451 kosher
+35452 krypton
+35453 kudos
+35454 kung
+35455 labored
+35456 laborer
+35461 laboring
+35462 laborious
+35463 labrador
+35464 ladder
+35465 ladies
+35466 ladle
+35511 ladybug
+35512 ladylike
+35513 lagged
+35514 lagging
+35515 lagoon
+35516 lair
+35521 lake
+35522 lance
+35523 landed
+35524 landfall
+35525 landfill
+35526 landing
+35531 landlady
+35532 landless
+35533 landline
+35534 landlord
+35535 landmark
+35536 landmass
+35541 landmine
+35542 landowner
+35543 landscape
+35544 landside
+35545 landslide
+35546 language
+35551 lankiness
+35552 lanky
+35553 lantern
+35554 lapdog
+35555 lapel
+35556 lapped
+35561 lapping
+35562 laptop
+35563 lard
+35564 large
+35565 lark
+35566 lash
+35611 lasso
+35612 last
+35613 latch
+35614 late
+35615 lather
+35616 latitude
+35621 latrine
+35622 latter
+35623 latticed
+35624 launch
+35625 launder
+35626 laundry
+35631 laurel
+35632 lavender
+35633 lavish
+35634 laxative
+35635 lazily
+35636 laziness
+35641 lazy
+35642 lecturer
+35643 left
+35644 legacy
+35645 legal
+35646 legend
+35651 legged
+35652 leggings
+35653 legible
+35654 legibly
+35655 legislate
+35656 lego
+35661 legroom
+35662 legume
+35663 legwarmer
+35664 legwork
+35665 lemon
+35666 lend
+36111 length
+36112 lens
+36113 lent
+36114 leotard
+36115 lesser
+36116 letdown
+36121 lethargic
+36122 lethargy
+36123 letter
+36124 lettuce
+36125 level
+36126 leverage
+36131 levers
+36132 levitate
+36133 levitator
+36134 liability
+36135 liable
+36136 liberty
+36141 librarian
+36142 library
+36143 licking
+36144 licorice
+36145 lid
+36146 life
+36151 lifter
+36152 lifting
+36153 liftoff
+36154 ligament
+36155 likely
+36156 likeness
+36161 likewise
+36162 liking
+36163 lilac
+36164 lilly
+36165 lily
+36166 limb
+36211 limeade
+36212 limelight
+36213 limes
+36214 limit
+36215 limping
+36216 limpness
+36221 line
+36222 lingo
+36223 linguini
+36224 linguist
+36225 lining
+36226 linked
+36231 linoleum
+36232 linseed
+36233 lint
+36234 lion
+36235 lip
+36236 liquefy
+36241 liqueur
+36242 liquid
+36243 lisp
+36244 list
+36245 litigate
+36246 litigator
+36251 litmus
+36252 litter
+36253 little
+36254 livable
+36255 lived
+36256 lively
+36261 liver
+36262 livestock
+36263 lividly
+36264 living
+36265 lizard
+36266 lubricant
+36311 lubricate
+36312 lucid
+36313 luckily
+36314 luckiness
+36315 luckless
+36316 lucrative
+36321 ludicrous
+36322 lugged
+36323 lukewarm
+36324 lullaby
+36325 lumber
+36326 luminance
+36331 luminous
+36332 lumpiness
+36333 lumping
+36334 lumpish
+36335 lunacy
+36336 lunar
+36341 lunchbox
+36342 luncheon
+36343 lunchroom
+36344 lunchtime
+36345 lung
+36346 lurch
+36351 lure
+36352 luridness
+36353 lurk
+36354 lushly
+36355 lushness
+36356 luster
+36361 lustfully
+36362 lustily
+36363 lustiness
+36364 lustrous
+36365 lusty
+36366 luxurious
+36411 luxury
+36412 lying
+36413 lyrically
+36414 lyricism
+36415 lyricist
+36416 lyrics
+36421 macarena
+36422 macaroni
+36423 macaw
+36424 mace
+36425 machine
+36426 machinist
+36431 magazine
+36432 magenta
+36433 maggot
+36434 magical
+36435 magician
+36436 magma
+36441 magnesium
+36442 magnetic
+36443 magnetism
+36444 magnetize
+36445 magnifier
+36446 magnify
+36451 magnitude
+36452 magnolia
+36453 mahogany
+36454 maimed
+36455 majestic
+36456 majesty
+36461 majorette
+36462 majority
+36463 makeover
+36464 maker
+36465 makeshift
+36466 making
+36511 malformed
+36512 malt
+36513 mama
+36514 mammal
+36515 mammary
+36516 mammogram
+36521 manager
+36522 managing
+36523 manatee
+36524 mandarin
+36525 mandate
+36526 mandatory
+36531 mandolin
+36532 manger
+36533 mangle
+36534 mango
+36535 mangy
+36536 manhandle
+36541 manhole
+36542 manhood
+36543 manhunt
+36544 manicotti
+36545 manicure
+36546 manifesto
+36551 manila
+36552 mankind
+36553 manlike
+36554 manliness
+36555 manly
+36556 manmade
+36561 manned
+36562 mannish
+36563 manor
+36564 manpower
+36565 mantis
+36566 mantra
+36611 manual
+36612 many
+36613 map
+36614 marathon
+36615 marauding
+36616 marbled
+36621 marbles
+36622 marbling
+36623 march
+36624 mardi
+36625 margarine
+36626 margarita
+36631 margin
+36632 marigold
+36633 marina
+36634 marine
+36635 marital
+36636 maritime
+36641 marlin
+36642 marmalade
+36643 maroon
+36644 married
+36645 marrow
+36646 marry
+36651 marshland
+36652 marshy
+36653 marsupial
+36654 marvelous
+36655 marxism
+36656 mascot
+36661 masculine
+36662 mashed
+36663 mashing
+36664 massager
+36665 masses
+36666 massive
+41111 mastiff
+41112 matador
+41113 matchbook
+41114 matchbox
+41115 matcher
+41116 matching
+41121 matchless
+41122 material
+41123 maternal
+41124 maternity
+41125 math
+41126 mating
+41131 matriarch
+41132 matrimony
+41133 matrix
+41134 matron
+41135 matted
+41136 matter
+41141 maturely
+41142 maturing
+41143 maturity
+41144 mauve
+41145 maverick
+41146 maximize
+41151 maximum
+41152 maybe
+41153 mayday
+41154 mayflower
+41155 moaner
+41156 moaning
+41161 mobile
+41162 mobility
+41163 mobilize
+41164 mobster
+41165 mocha
+41166 mocker
+41211 mockup
+41212 modified
+41213 modify
+41214 modular
+41215 modulator
+41216 module
+41221 moisten
+41222 moistness
+41223 moisture
+41224 molar
+41225 molasses
+41226 mold
+41231 molecular
+41232 molecule
+41233 molehill
+41234 mollusk
+41235 mom
+41236 monastery
+41241 monday
+41242 monetary
+41243 monetize
+41244 moneybags
+41245 moneyless
+41246 moneywise
+41251 mongoose
+41252 mongrel
+41253 monitor
+41254 monkhood
+41255 monogamy
+41256 monogram
+41261 monologue
+41262 monopoly
+41263 monorail
+41264 monotone
+41265 monotype
+41266 monoxide
+41311 monsieur
+41312 monsoon
+41313 monstrous
+41314 monthly
+41315 monument
+41316 moocher
+41321 moodiness
+41322 moody
+41323 mooing
+41324 moonbeam
+41325 mooned
+41326 moonlight
+41331 moonlike
+41332 moonlit
+41333 moonrise
+41334 moonscape
+41335 moonshine
+41336 moonstone
+41341 moonwalk
+41342 mop
+41343 morale
+41344 morality
+41345 morally
+41346 morbidity
+41351 morbidly
+41352 morphine
+41353 morphing
+41354 morse
+41355 mortality
+41356 mortally
+41361 mortician
+41362 mortified
+41363 mortify
+41364 mortuary
+41365 mosaic
+41366 mossy
+41411 most
+41412 mothball
+41413 mothproof
+41414 motion
+41415 motivate
+41416 motivator
+41421 motive
+41422 motocross
+41423 motor
+41424 motto
+41425 mountable
+41426 mountain
+41431 mounted
+41432 mounting
+41433 mourner
+41434 mournful
+41435 mouse
+41436 mousiness
+41441 moustache
+41442 mousy
+41443 mouth
+41444 movable
+41445 move
+41446 movie
+41451 moving
+41452 mower
+41453 mowing
+41454 much
+41455 muck
+41456 mud
+41461 mug
+41462 mulberry
+41463 mulch
+41464 mule
+41465 mulled
+41466 mullets
+41511 multiple
+41512 multiply
+41513 multitask
+41514 multitude
+41515 mumble
+41516 mumbling
+41521 mumbo
+41522 mummified
+41523 mummify
+41524 mummy
+41525 mumps
+41526 munchkin
+41531 mundane
+41532 municipal
+41533 muppet
+41534 mural
+41535 murkiness
+41536 murky
+41541 murmuring
+41542 muscular
+41543 museum
+41544 mushily
+41545 mushiness
+41546 mushroom
+41551 mushy
+41552 music
+41553 musket
+41554 muskiness
+41555 musky
+41556 mustang
+41561 mustard
+41562 muster
+41563 mustiness
+41564 musty
+41565 mutable
+41566 mutate
+41611 mutation
+41612 mute
+41613 mutilated
+41614 mutilator
+41615 mutiny
+41616 mutt
+41621 mutual
+41622 muzzle
+41623 myself
+41624 myspace
+41625 mystified
+41626 mystify
+41631 myth
+41632 nacho
+41633 nag
+41634 nail
+41635 name
+41636 naming
+41641 nanny
+41642 nanometer
+41643 nape
+41644 napkin
+41645 napped
+41646 napping
+41651 nappy
+41652 narrow
+41653 nastily
+41654 nastiness
+41655 national
+41656 native
+41661 nativity
+41662 natural
+41663 nature
+41664 naturist
+41665 nautical
+41666 navigate
+42111 navigator
+42112 navy
+42113 nearby
+42114 nearest
+42115 nearly
+42116 nearness
+42121 neatly
+42122 neatness
+42123 nebula
+42124 nebulizer
+42125 nectar
+42126 negate
+42131 negation
+42132 negative
+42133 neglector
+42134 negligee
+42135 negligent
+42136 negotiate
+42141 nemeses
+42142 nemesis
+42143 neon
+42144 nephew
+42145 nerd
+42146 nervous
+42151 nervy
+42152 nest
+42153 net
+42154 neurology
+42155 neuron
+42156 neurosis
+42161 neurotic
+42162 neuter
+42163 neutron
+42164 never
+42165 next
+42166 nibble
+42211 nickname
+42212 nicotine
+42213 niece
+42214 nifty
+42215 nimble
+42216 nimbly
+42221 nineteen
+42222 ninetieth
+42223 ninja
+42224 nintendo
+42225 ninth
+42226 nuclear
+42231 nuclei
+42232 nucleus
+42233 nugget
+42234 nullify
+42235 number
+42236 numbing
+42241 numbly
+42242 numbness
+42243 numeral
+42244 numerate
+42245 numerator
+42246 numeric
+42251 numerous
+42252 nuptials
+42253 nursery
+42254 nursing
+42255 nurture
+42256 nutcase
+42261 nutlike
+42262 nutmeg
+42263 nutrient
+42264 nutshell
+42265 nuttiness
+42266 nutty
+42311 nuzzle
+42312 nylon
+42313 oaf
+42314 oak
+42315 oasis
+42316 oat
+42321 obedience
+42322 obedient
+42323 obituary
+42324 object
+42325 obligate
+42326 obliged
+42331 oblivion
+42332 oblivious
+42333 oblong
+42334 obnoxious
+42335 oboe
+42336 obscure
+42341 obscurity
+42342 observant
+42343 observer
+42344 observing
+42345 obsessed
+42346 obsession
+42351 obsessive
+42352 obsolete
+42353 obstacle
+42354 obstinate
+42355 obstruct
+42356 obtain
+42361 obtrusive
+42362 obtuse
+42363 obvious
+42364 occultist
+42365 occupancy
+42366 occupant
+42411 occupier
+42412 occupy
+42413 ocean
+42414 ocelot
+42415 octagon
+42416 octane
+42421 october
+42422 octopus
+42423 ogle
+42424 oil
+42425 oink
+42426 ointment
+42431 okay
+42432 old
+42433 olive
+42434 olympics
+42435 omega
+42436 omen
+42441 ominous
+42442 omission
+42443 omit
+42444 omnivore
+42445 onboard
+42446 oncoming
+42451 ongoing
+42452 onion
+42453 online
+42454 onlooker
+42455 only
+42456 onscreen
+42461 onset
+42462 onshore
+42463 onslaught
+42464 onstage
+42465 onto
+42466 onward
+42511 onyx
+42512 oops
+42513 ooze
+42514 oozy
+42515 opacity
+42516 opal
+42521 open
+42522 operable
+42523 operate
+42524 operating
+42525 operation
+42526 operative
+42531 operator
+42532 opium
+42533 opossum
+42534 opponent
+42535 oppose
+42536 opposing
+42541 opposite
+42542 oppressed
+42543 oppressor
+42544 opt
+42545 opulently
+42546 osmosis
+42551 other
+42552 otter
+42553 ouch
+42554 ought
+42555 ounce
+42556 outage
+42561 outback
+42562 outbid
+42563 outboard
+42564 outbound
+42565 outbreak
+42566 outburst
+42611 outcast
+42612 outclass
+42613 outcome
+42614 outdated
+42615 outdoors
+42616 outer
+42621 outfield
+42622 outfit
+42623 outflank
+42624 outgoing
+42625 outgrow
+42626 outhouse
+42631 outing
+42632 outlast
+42633 outlet
+42634 outline
+42635 outlook
+42636 outlying
+42641 outmatch
+42642 outmost
+42643 outnumber
+42644 outplayed
+42645 outpost
+42646 outpour
+42651 output
+42652 outrage
+42653 outrank
+42654 outreach
+42655 outright
+42656 outscore
+42661 outsell
+42662 outshine
+42663 outshoot
+42664 outsider
+42665 outskirts
+42666 outsmart
+43111 outsource
+43112 outspoken
+43113 outtakes
+43114 outthink
+43115 outward
+43116 outweigh
+43121 outwit
+43122 oval
+43123 ovary
+43124 oven
+43125 overact
+43126 overall
+43131 overarch
+43132 overbid
+43133 overbill
+43134 overbite
+43135 overblown
+43136 overboard
+43141 overbook
+43142 overbuilt
+43143 overcast
+43144 overcoat
+43145 overcome
+43146 overcook
+43151 overcrowd
+43152 overdraft
+43153 overdrawn
+43154 overdress
+43155 overdrive
+43156 overdue
+43161 overeager
+43162 overeater
+43163 overexert
+43164 overfed
+43165 overfeed
+43166 overfill
+43211 overflow
+43212 overfull
+43213 overgrown
+43214 overhand
+43215 overhang
+43216 overhaul
+43221 overhead
+43222 overhear
+43223 overheat
+43224 overhung
+43225 overjoyed
+43226 overkill
+43231 overlabor
+43232 overlaid
+43233 overlap
+43234 overlay
+43235 overload
+43236 overlook
+43241 overlord
+43242 overlying
+43243 overnight
+43244 overpass
+43245 overpay
+43246 overplant
+43251 overplay
+43252 overpower
+43253 overprice
+43254 overrate
+43255 overreach
+43256 overreact
+43261 override
+43262 overripe
+43263 overrule
+43264 overrun
+43265 overshoot
+43266 overshot
+43311 oversight
+43312 oversized
+43313 oversleep
+43314 oversold
+43315 overspend
+43316 overstate
+43321 overstay
+43322 overstep
+43323 overstock
+43324 overstuff
+43325 oversweet
+43326 overtake
+43331 overthrow
+43332 overtime
+43333 overtly
+43334 overtone
+43335 overture
+43336 overturn
+43341 overuse
+43342 overvalue
+43343 overview
+43344 overwrite
+43345 owl
+43346 oxford
+43351 oxidant
+43352 oxidation
+43353 oxidize
+43354 oxidizing
+43355 oxygen
+43356 oxymoron
+43361 oyster
+43362 ozone
+43363 paced
+43364 pacemaker
+43365 pacific
+43366 pacifier
+43411 pacifism
+43412 pacifist
+43413 pacify
+43414 padded
+43415 padding
+43416 paddle
+43421 paddling
+43422 padlock
+43423 pagan
+43424 pager
+43425 paging
+43426 pajamas
+43431 palace
+43432 palatable
+43433 palm
+43434 palpable
+43435 palpitate
+43436 paltry
+43441 pampered
+43442 pamperer
+43443 pampers
+43444 pamphlet
+43445 panama
+43446 pancake
+43451 pancreas
+43452 panda
+43453 pandemic
+43454 pang
+43455 panhandle
+43456 panic
+43461 panning
+43462 panorama
+43463 panoramic
+43464 panther
+43465 pantomime
+43466 pantry
+43511 pants
+43512 pantyhose
+43513 paparazzi
+43514 papaya
+43515 paper
+43516 paprika
+43521 papyrus
+43522 parabola
+43523 parachute
+43524 parade
+43525 paradox
+43526 paragraph
+43531 parakeet
+43532 paralegal
+43533 paralyses
+43534 paralysis
+43535 paralyze
+43536 paramedic
+43541 parameter
+43542 paramount
+43543 parasail
+43544 parasite
+43545 parasitic
+43546 parcel
+43551 parched
+43552 parchment
+43553 pardon
+43554 parish
+43555 parka
+43556 parking
+43561 parkway
+43562 parlor
+43563 parmesan
+43564 parole
+43565 parrot
+43566 parsley
+43611 parsnip
+43612 partake
+43613 parted
+43614 parting
+43615 partition
+43616 partly
+43621 partner
+43622 partridge
+43623 party
+43624 passable
+43625 passably
+43626 passage
+43631 passcode
+43632 passenger
+43633 passerby
+43634 passing
+43635 passion
+43636 passive
+43641 passivism
+43642 passover
+43643 passport
+43644 password
+43645 pasta
+43646 pasted
+43651 pastel
+43652 pastime
+43653 pastor
+43654 pastrami
+43655 pasture
+43656 pasty
+43661 patchwork
+43662 patchy
+43663 paternal
+43664 paternity
+43665 path
+43666 patience
+44111 patient
+44112 patio
+44113 patriarch
+44114 patriot
+44115 patrol
+44116 patronage
+44121 patronize
+44122 pauper
+44123 pavement
+44124 paver
+44125 pavestone
+44126 pavilion
+44131 paving
+44132 pawing
+44133 payable
+44134 payback
+44135 paycheck
+44136 payday
+44141 payee
+44142 payer
+44143 paying
+44144 payment
+44145 payphone
+44146 payroll
+44151 pebble
+44152 pebbly
+44153 pecan
+44154 pectin
+44155 peculiar
+44156 peddling
+44161 pediatric
+44162 pedicure
+44163 pedigree
+44164 pedometer
+44165 pegboard
+44166 pelican
+44211 pellet
+44212 pelt
+44213 pelvis
+44214 penalize
+44215 penalty
+44216 pencil
+44221 pendant
+44222 pending
+44223 penholder
+44224 penknife
+44225 pennant
+44226 penniless
+44231 penny
+44232 penpal
+44233 pension
+44234 pentagon
+44235 pentagram
+44236 pep
+44241 perceive
+44242 percent
+44243 perch
+44244 percolate
+44245 perennial
+44246 perfected
+44251 perfectly
+44252 perfume
+44253 periscope
+44254 perish
+44255 perjurer
+44256 perjury
+44261 perkiness
+44262 perky
+44263 perm
+44264 peroxide
+44265 perpetual
+44266 perplexed
+44311 persecute
+44312 persevere
+44313 persuaded
+44314 persuader
+44315 pesky
+44316 peso
+44321 pessimism
+44322 pessimist
+44323 pester
+44324 pesticide
+44325 petal
+44326 petite
+44331 petition
+44332 petri
+44333 petroleum
+44334 petted
+44335 petticoat
+44336 pettiness
+44341 petty
+44342 petunia
+44343 phantom
+44344 phobia
+44345 phoenix
+44346 phonebook
+44351 phoney
+44352 phonics
+44353 phoniness
+44354 phony
+44355 phosphate
+44356 photo
+44361 phrase
+44362 phrasing
+44363 placard
+44364 placate
+44365 placidly
+44366 plank
+44411 planner
+44412 plant
+44413 plasma
+44414 plaster
+44415 plastic
+44416 plated
+44421 platform
+44422 plating
+44423 platinum
+44424 platonic
+44425 platter
+44426 platypus
+44431 plausible
+44432 plausibly
+44433 playable
+44434 playback
+44435 player
+44436 playful
+44441 playgroup
+44442 playhouse
+44443 playing
+44444 playlist
+44445 playmaker
+44446 playmate
+44451 playoff
+44452 playpen
+44453 playroom
+44454 playset
+44455 plaything
+44456 playtime
+44461 plaza
+44462 pleading
+44463 pleat
+44464 pledge
+44465 plentiful
+44466 plenty
+44511 plethora
+44512 plexiglas
+44513 pliable
+44514 plod
+44515 plop
+44516 plot
+44521 plow
+44522 ploy
+44523 pluck
+44524 plug
+44525 plunder
+44526 plunging
+44531 plural
+44532 plus
+44533 plutonium
+44534 plywood
+44535 poach
+44536 pod
+44541 poem
+44542 poet
+44543 pogo
+44544 pointed
+44545 pointer
+44546 pointing
+44551 pointless
+44552 pointy
+44553 poise
+44554 poison
+44555 poker
+44556 poking
+44561 polar
+44562 police
+44563 policy
+44564 polio
+44565 polish
+44566 politely
+44611 polka
+44612 polo
+44613 polyester
+44614 polygon
+44615 polygraph
+44616 polymer
+44621 poncho
+44622 pond
+44623 pony
+44624 popcorn
+44625 pope
+44626 poplar
+44631 popper
+44632 poppy
+44633 popsicle
+44634 populace
+44635 popular
+44636 populate
+44641 porcupine
+44642 pork
+44643 porous
+44644 porridge
+44645 portable
+44646 portal
+44651 portfolio
+44652 porthole
+44653 portion
+44654 portly
+44655 portside
+44656 poser
+44661 posh
+44662 posing
+44663 possible
+44664 possibly
+44665 possum
+44666 postage
+45111 postal
+45112 postbox
+45113 postcard
+45114 posted
+45115 poster
+45116 posting
+45121 postnasal
+45122 posture
+45123 postwar
+45124 pouch
+45125 pounce
+45126 pouncing
+45131 pound
+45132 pouring
+45133 pout
+45134 powdered
+45135 powdering
+45136 powdery
+45141 power
+45142 powwow
+45143 pox
+45144 praising
+45145 prance
+45146 prancing
+45151 pranker
+45152 prankish
+45153 prankster
+45154 prayer
+45155 praying
+45156 preacher
+45161 preaching
+45162 preachy
+45163 preamble
+45164 precinct
+45165 precise
+45166 precision
+45211 precook
+45212 precut
+45213 predator
+45214 predefine
+45215 predict
+45216 preface
+45221 prefix
+45222 preflight
+45223 preformed
+45224 pregame
+45225 pregnancy
+45226 pregnant
+45231 preheated
+45232 prelaunch
+45233 prelaw
+45234 prelude
+45235 premiere
+45236 premises
+45241 premium
+45242 prenatal
+45243 preoccupy
+45244 preorder
+45245 prepaid
+45246 prepay
+45251 preplan
+45252 preppy
+45253 preschool
+45254 prescribe
+45255 preseason
+45256 preset
+45261 preshow
+45262 president
+45263 presoak
+45264 press
+45265 presume
+45266 presuming
+45311 preteen
+45312 pretended
+45313 pretender
+45314 pretense
+45315 pretext
+45316 pretty
+45321 pretzel
+45322 prevail
+45323 prevalent
+45324 prevent
+45325 preview
+45326 previous
+45331 prewar
+45332 prewashed
+45333 prideful
+45334 pried
+45335 primal
+45336 primarily
+45341 primary
+45342 primate
+45343 primer
+45344 primp
+45345 princess
+45346 print
+45351 prior
+45352 prism
+45353 prison
+45354 prissy
+45355 pristine
+45356 privacy
+45361 private
+45362 privatize
+45363 prize
+45364 proactive
+45365 probable
+45366 probably
+45411 probation
+45412 probe
+45413 probing
+45414 probiotic
+45415 problem
+45416 procedure
+45421 process
+45422 proclaim
+45423 procreate
+45424 procurer
+45425 prodigal
+45426 prodigy
+45431 produce
+45432 product
+45433 profane
+45434 profanity
+45435 professed
+45436 professor
+45441 profile
+45442 profound
+45443 profusely
+45444 progeny
+45445 prognosis
+45446 program
+45451 progress
+45452 projector
+45453 prologue
+45454 prolonged
+45455 promenade
+45456 prominent
+45461 promoter
+45462 promotion
+45463 prompter
+45464 promptly
+45465 prone
+45466 prong
+45511 pronounce
+45512 pronto
+45513 proofing
+45514 proofread
+45515 proofs
+45516 propeller
+45521 properly
+45522 property
+45523 proponent
+45524 proposal
+45525 propose
+45526 props
+45531 prorate
+45532 protector
+45533 protegee
+45534 proton
+45535 prototype
+45536 protozoan
+45541 protract
+45542 protrude
+45543 proud
+45544 provable
+45545 proved
+45546 proven
+45551 provided
+45552 provider
+45553 providing
+45554 province
+45555 proving
+45556 provoke
+45561 provoking
+45562 provolone
+45563 prowess
+45564 prowler
+45565 prowling
+45566 proximity
+45611 proxy
+45612 prozac
+45613 prude
+45614 prudishly
+45615 prune
+45616 pruning
+45621 pry
+45622 psychic
+45623 public
+45624 publisher
+45625 pucker
+45626 pueblo
+45631 pug
+45632 pull
+45633 pulmonary
+45634 pulp
+45635 pulsate
+45636 pulse
+45641 pulverize
+45642 puma
+45643 pumice
+45644 pummel
+45645 punch
+45646 punctual
+45651 punctuate
+45652 punctured
+45653 pungent
+45654 punisher
+45655 punk
+45656 pupil
+45661 puppet
+45662 puppy
+45663 purchase
+45664 pureblood
+45665 purebred
+45666 purely
+46111 pureness
+46112 purgatory
+46113 purge
+46114 purging
+46115 purifier
+46116 purify
+46121 purist
+46122 puritan
+46123 purity
+46124 purple
+46125 purplish
+46126 purposely
+46131 purr
+46132 purse
+46133 pursuable
+46134 pursuant
+46135 pursuit
+46136 purveyor
+46141 pushcart
+46142 pushchair
+46143 pusher
+46144 pushiness
+46145 pushing
+46146 pushover
+46151 pushpin
+46152 pushup
+46153 pushy
+46154 putdown
+46155 putt
+46156 puzzle
+46161 puzzling
+46162 pyramid
+46163 pyromania
+46164 python
+46165 quack
+46166 quadrant
+46211 quail
+46212 quaintly
+46213 quake
+46214 quaking
+46215 qualified
+46216 qualifier
+46221 qualify
+46222 quality
+46223 qualm
+46224 quantum
+46225 quarrel
+46226 quarry
+46231 quartered
+46232 quarterly
+46233 quarters
+46234 quartet
+46235 quench
+46236 query
+46241 quicken
+46242 quickly
+46243 quickness
+46244 quicksand
+46245 quickstep
+46246 quiet
+46251 quill
+46252 quilt
+46253 quintet
+46254 quintuple
+46255 quirk
+46256 quit
+46261 quiver
+46262 quizzical
+46263 quotable
+46264 quotation
+46265 quote
+46266 rabid
+46311 race
+46312 racing
+46313 racism
+46314 rack
+46315 racoon
+46316 radar
+46321 radial
+46322 radiance
+46323 radiantly
+46324 radiated
+46325 radiation
+46326 radiator
+46331 radio
+46332 radish
+46333 raffle
+46334 raft
+46335 rage
+46336 ragged
+46341 raging
+46342 ragweed
+46343 raider
+46344 railcar
+46345 railing
+46346 railroad
+46351 railway
+46352 raisin
+46353 rake
+46354 raking
+46355 rally
+46356 ramble
+46361 rambling
+46362 ramp
+46363 ramrod
+46364 ranch
+46365 rancidity
+46366 random
+46411 ranged
+46412 ranger
+46413 ranging
+46414 ranked
+46415 ranking
+46416 ransack
+46421 ranting
+46422 rants
+46423 rare
+46424 rarity
+46425 rascal
+46426 rash
+46431 rasping
+46432 ravage
+46433 raven
+46434 ravine
+46435 raving
+46436 ravioli
+46441 ravishing
+46442 reabsorb
+46443 reach
+46444 reacquire
+46445 reaction
+46446 reactive
+46451 reactor
+46452 reaffirm
+46453 ream
+46454 reanalyze
+46455 reappear
+46456 reapply
+46461 reappoint
+46462 reapprove
+46463 rearrange
+46464 rearview
+46465 reason
+46466 reassign
+46511 reassure
+46512 reattach
+46513 reawake
+46514 rebalance
+46515 rebate
+46516 rebel
+46521 rebirth
+46522 reboot
+46523 reborn
+46524 rebound
+46525 rebuff
+46526 rebuild
+46531 rebuilt
+46532 reburial
+46533 rebuttal
+46534 recall
+46535 recant
+46536 recapture
+46541 recast
+46542 recede
+46543 recent
+46544 recess
+46545 recharger
+46546 recipient
+46551 recital
+46552 recite
+46553 reckless
+46554 reclaim
+46555 recliner
+46556 reclining
+46561 recluse
+46562 reclusive
+46563 recognize
+46564 recoil
+46565 recollect
+46566 recolor
+46611 reconcile
+46612 reconfirm
+46613 reconvene
+46614 recopy
+46615 record
+46616 recount
+46621 recoup
+46622 recovery
+46623 recreate
+46624 rectal
+46625 rectangle
+46626 rectified
+46631 rectify
+46632 recycled
+46633 recycler
+46634 recycling
+46635 reemerge
+46636 reenact
+46641 reenter
+46642 reentry
+46643 reexamine
+46644 referable
+46645 referee
+46646 reference
+46651 refill
+46652 refinance
+46653 refined
+46654 refinery
+46655 refining
+46656 refinish
+46661 reflected
+46662 reflector
+46663 reflex
+46664 reflux
+46665 refocus
+46666 refold
+51111 reforest
+51112 reformat
+51113 reformed
+51114 reformer
+51115 reformist
+51116 refract
+51121 refrain
+51122 refreeze
+51123 refresh
+51124 refried
+51125 refueling
+51126 refund
+51131 refurbish
+51132 refurnish
+51133 refusal
+51134 refuse
+51135 refusing
+51136 refutable
+51141 refute
+51142 regain
+51143 regalia
+51144 regally
+51145 reggae
+51146 regime
+51151 region
+51152 register
+51153 registrar
+51154 registry
+51155 regress
+51156 regretful
+51161 regroup
+51162 regular
+51163 regulate
+51164 regulator
+51165 rehab
+51166 reheat
+51211 rehire
+51212 rehydrate
+51213 reimburse
+51214 reissue
+51215 reiterate
+51216 rejoice
+51221 rejoicing
+51222 rejoin
+51223 rekindle
+51224 relapse
+51225 relapsing
+51226 relatable
+51231 related
+51232 relation
+51233 relative
+51234 relax
+51235 relay
+51236 relearn
+51241 release
+51242 relenting
+51243 reliable
+51244 reliably
+51245 reliance
+51246 reliant
+51251 relic
+51252 relieve
+51253 relieving
+51254 relight
+51255 relish
+51256 relive
+51261 reload
+51262 relocate
+51263 relock
+51264 reluctant
+51265 rely
+51266 remake
+51311 remark
+51312 remarry
+51313 rematch
+51314 remedial
+51315 remedy
+51316 remember
+51321 reminder
+51322 remindful
+51323 remission
+51324 remix
+51325 remnant
+51326 remodeler
+51331 remold
+51332 remorse
+51333 remote
+51334 removable
+51335 removal
+51336 removed
+51341 remover
+51342 removing
+51343 rename
+51344 renderer
+51345 rendering
+51346 rendition
+51351 renegade
+51352 renewable
+51353 renewably
+51354 renewal
+51355 renewed
+51356 renounce
+51361 renovate
+51362 renovator
+51363 rentable
+51364 rental
+51365 rented
+51366 renter
+51411 reoccupy
+51412 reoccur
+51413 reopen
+51414 reorder
+51415 repackage
+51416 repacking
+51421 repaint
+51422 repair
+51423 repave
+51424 repaying
+51425 repayment
+51426 repeal
+51431 repeated
+51432 repeater
+51433 repent
+51434 rephrase
+51435 replace
+51436 replay
+51441 replica
+51442 reply
+51443 reporter
+51444 repose
+51445 repossess
+51446 repost
+51451 repressed
+51452 reprimand
+51453 reprint
+51454 reprise
+51455 reproach
+51456 reprocess
+51461 reproduce
+51462 reprogram
+51463 reps
+51464 reptile
+51465 reptilian
+51466 repugnant
+51511 repulsion
+51512 repulsive
+51513 repurpose
+51514 reputable
+51515 reputably
+51516 request
+51521 require
+51522 requisite
+51523 reroute
+51524 rerun
+51525 resale
+51526 resample
+51531 rescuer
+51532 reseal
+51533 research
+51534 reselect
+51535 reseller
+51536 resemble
+51541 resend
+51542 resent
+51543 reset
+51544 reshape
+51545 reshoot
+51546 reshuffle
+51551 residence
+51552 residency
+51553 resident
+51554 residual
+51555 residue
+51556 resigned
+51561 resilient
+51562 resistant
+51563 resisting
+51564 resize
+51565 resolute
+51566 resolved
+51611 resonant
+51612 resonate
+51613 resort
+51614 resource
+51615 respect
+51616 resubmit
+51621 result
+51622 resume
+51623 resupply
+51624 resurface
+51625 resurrect
+51626 retail
+51631 retainer
+51632 retaining
+51633 retake
+51634 retaliate
+51635 retention
+51636 rethink
+51641 retinal
+51642 retired
+51643 retiree
+51644 retiring
+51645 retold
+51646 retool
+51651 retorted
+51652 retouch
+51653 retrace
+51654 retract
+51655 retrain
+51656 retread
+51661 retreat
+51662 retrial
+51663 retrieval
+51664 retriever
+51665 retry
+51666 return
+52111 retying
+52112 retype
+52113 reunion
+52114 reunite
+52115 reusable
+52116 reuse
+52121 reveal
+52122 reveler
+52123 revenge
+52124 revenue
+52125 reverb
+52126 revered
+52131 reverence
+52132 reverend
+52133 reversal
+52134 reverse
+52135 reversing
+52136 reversion
+52141 revert
+52142 revisable
+52143 revise
+52144 revision
+52145 revisit
+52146 revivable
+52151 revival
+52152 reviver
+52153 reviving
+52154 revocable
+52155 revoke
+52156 revolt
+52161 revolver
+52162 revolving
+52163 reward
+52164 rewash
+52165 rewind
+52166 rewire
+52211 reword
+52212 rework
+52213 rewrap
+52214 rewrite
+52215 rhyme
+52216 ribbon
+52221 ribcage
+52222 rice
+52223 riches
+52224 richly
+52225 richness
+52226 rickety
+52231 ricotta
+52232 riddance
+52233 ridden
+52234 ride
+52235 riding
+52236 rifling
+52241 rift
+52242 rigging
+52243 rigid
+52244 rigor
+52245 rimless
+52246 rimmed
+52251 rind
+52252 rink
+52253 rinse
+52254 rinsing
+52255 riot
+52256 ripcord
+52261 ripeness
+52262 ripening
+52263 ripping
+52264 ripple
+52265 rippling
+52266 riptide
+52311 rise
+52312 rising
+52313 risk
+52314 risotto
+52315 ritalin
+52316 ritzy
+52321 rival
+52322 riverbank
+52323 riverbed
+52324 riverboat
+52325 riverside
+52326 riveter
+52331 riveting
+52332 roamer
+52333 roaming
+52334 roast
+52335 robbing
+52336 robe
+52341 robin
+52342 robotics
+52343 robust
+52344 rockband
+52345 rocker
+52346 rocket
+52351 rockfish
+52352 rockiness
+52353 rocking
+52354 rocklike
+52355 rockslide
+52356 rockstar
+52361 rocky
+52362 rogue
+52363 roman
+52364 romp
+52365 rope
+52366 roping
+52411 roster
+52412 rosy
+52413 rotten
+52414 rotting
+52415 rotunda
+52416 roulette
+52421 rounding
+52422 roundish
+52423 roundness
+52424 roundup
+52425 roundworm
+52426 routine
+52431 routing
+52432 rover
+52433 roving
+52434 royal
+52435 rubbed
+52436 rubber
+52441 rubbing
+52442 rubble
+52443 rubdown
+52444 ruby
+52445 ruckus
+52446 rudder
+52451 rug
+52452 ruined
+52453 rule
+52454 rumble
+52455 rumbling
+52456 rummage
+52461 rumor
+52462 runaround
+52463 rundown
+52464 runner
+52465 running
+52466 runny
+52511 runt
+52512 runway
+52513 rupture
+52514 rural
+52515 ruse
+52516 rush
+52521 rust
+52522 rut
+52523 sabbath
+52524 sabotage
+52525 sacrament
+52526 sacred
+52531 sacrifice
+52532 sadden
+52533 saddlebag
+52534 saddled
+52535 saddling
+52536 sadly
+52541 sadness
+52542 safari
+52543 safeguard
+52544 safehouse
+52545 safely
+52546 safeness
+52551 saffron
+52552 saga
+52553 sage
+52554 sagging
+52555 saggy
+52556 said
+52561 saint
+52562 sake
+52563 salad
+52564 salami
+52565 salaried
+52566 salary
+52611 saline
+52612 salon
+52613 saloon
+52614 salsa
+52615 salt
+52616 salutary
+52621 salute
+52622 salvage
+52623 salvaging
+52624 salvation
+52625 same
+52626 sample
+52631 sampling
+52632 sanction
+52633 sanctity
+52634 sanctuary
+52635 sandal
+52636 sandbag
+52641 sandbank
+52642 sandbar
+52643 sandblast
+52644 sandbox
+52645 sanded
+52646 sandfish
+52651 sanding
+52652 sandlot
+52653 sandpaper
+52654 sandpit
+52655 sandstone
+52656 sandstorm
+52661 sandworm
+52662 sandy
+52663 sanitary
+52664 sanitizer
+52665 sank
+52666 santa
+53111 sapling
+53112 sappiness
+53113 sappy
+53114 sarcasm
+53115 sarcastic
+53116 sardine
+53121 sash
+53122 sasquatch
+53123 sassy
+53124 satchel
+53125 satiable
+53126 satin
+53131 satirical
+53132 satisfied
+53133 satisfy
+53134 saturate
+53135 saturday
+53136 sauciness
+53141 saucy
+53142 sauna
+53143 savage
+53144 savanna
+53145 saved
+53146 savings
+53151 savior
+53152 savor
+53153 saxophone
+53154 say
+53155 scabbed
+53156 scabby
+53161 scalded
+53162 scalding
+53163 scale
+53164 scaling
+53165 scallion
+53166 scallop
+53211 scalping
+53212 scam
+53213 scandal
+53214 scanner
+53215 scanning
+53216 scant
+53221 scapegoat
+53222 scarce
+53223 scarcity
+53224 scarecrow
+53225 scared
+53226 scarf
+53231 scarily
+53232 scariness
+53233 scarring
+53234 scary
+53235 scavenger
+53236 scenic
+53241 schedule
+53242 schematic
+53243 scheme
+53244 scheming
+53245 schilling
+53246 schnapps
+53251 scholar
+53252 science
+53253 scientist
+53254 scion
+53255 scoff
+53256 scolding
+53261 scone
+53262 scoop
+53263 scooter
+53264 scope
+53265 scorch
+53266 scorebook
+53311 scorecard
+53312 scored
+53313 scoreless
+53314 scorer
+53315 scoring
+53316 scorn
+53321 scorpion
+53322 scotch
+53323 scoundrel
+53324 scoured
+53325 scouring
+53326 scouting
+53331 scouts
+53332 scowling
+53333 scrabble
+53334 scraggly
+53335 scrambled
+53336 scrambler
+53341 scrap
+53342 scratch
+53343 scrawny
+53344 screen
+53345 scribble
+53346 scribe
+53351 scribing
+53352 scrimmage
+53353 script
+53354 scroll
+53355 scrooge
+53356 scrounger
+53361 scrubbed
+53362 scrubber
+53363 scruffy
+53364 scrunch
+53365 scrutiny
+53366 scuba
+53411 scuff
+53412 sculptor
+53413 sculpture
+53414 scurvy
+53415 scuttle
+53416 secluded
+53421 secluding
+53422 seclusion
+53423 second
+53424 secrecy
+53425 secret
+53426 sectional
+53431 sector
+53432 secular
+53433 securely
+53434 security
+53435 sedan
+53436 sedate
+53441 sedation
+53442 sedative
+53443 sediment
+53444 seduce
+53445 seducing
+53446 segment
+53451 seismic
+53452 seizing
+53453 seldom
+53454 selected
+53455 selection
+53456 selective
+53461 selector
+53462 self
+53463 seltzer
+53464 semantic
+53465 semester
+53466 semicolon
+53511 semifinal
+53512 seminar
+53513 semisoft
+53514 semisweet
+53515 senate
+53516 senator
+53521 send
+53522 senior
+53523 senorita
+53524 sensation
+53525 sensitive
+53526 sensitize
+53531 sensually
+53532 sensuous
+53533 sepia
+53534 september
+53535 septic
+53536 septum
+53541 sequel
+53542 sequence
+53543 sequester
+53544 series
+53545 sermon
+53546 serotonin
+53551 serpent
+53552 serrated
+53553 serve
+53554 service
+53555 serving
+53556 sesame
+53561 sessions
+53562 setback
+53563 setting
+53564 settle
+53565 settling
+53566 setup
+53611 sevenfold
+53612 seventeen
+53613 seventh
+53614 seventy
+53615 severity
+53616 shabby
+53621 shack
+53622 shaded
+53623 shadily
+53624 shadiness
+53625 shading
+53626 shadow
+53631 shady
+53632 shaft
+53633 shakable
+53634 shakily
+53635 shakiness
+53636 shaking
+53641 shaky
+53642 shale
+53643 shallot
+53644 shallow
+53645 shame
+53646 shampoo
+53651 shamrock
+53652 shank
+53653 shanty
+53654 shape
+53655 shaping
+53656 share
+53661 sharpener
+53662 sharper
+53663 sharpie
+53664 sharply
+53665 sharpness
+53666 shawl
+54111 sheath
+54112 shed
+54113 sheep
+54114 sheet
+54115 shelf
+54116 shell
+54121 shelter
+54122 shelve
+54123 shelving
+54124 sherry
+54125 shield
+54126 shifter
+54131 shifting
+54132 shiftless
+54133 shifty
+54134 shimmer
+54135 shimmy
+54136 shindig
+54141 shine
+54142 shingle
+54143 shininess
+54144 shining
+54145 shiny
+54146 ship
+54151 shirt
+54152 shivering
+54153 shock
+54154 shone
+54155 shoplift
+54156 shopper
+54161 shopping
+54162 shoptalk
+54163 shore
+54164 shortage
+54165 shortcake
+54166 shortcut
+54211 shorten
+54212 shorter
+54213 shorthand
+54214 shortlist
+54215 shortly
+54216 shortness
+54221 shorts
+54222 shortwave
+54223 shorty
+54224 shout
+54225 shove
+54226 showbiz
+54231 showcase
+54232 showdown
+54233 shower
+54234 showgirl
+54235 showing
+54236 showman
+54241 shown
+54242 showoff
+54243 showpiece
+54244 showplace
+54245 showroom
+54246 showy
+54251 shrank
+54252 shrapnel
+54253 shredder
+54254 shredding
+54255 shrewdly
+54256 shriek
+54261 shrill
+54262 shrimp
+54263 shrine
+54264 shrink
+54265 shrivel
+54266 shrouded
+54311 shrubbery
+54312 shrubs
+54313 shrug
+54314 shrunk
+54315 shucking
+54316 shudder
+54321 shuffle
+54322 shuffling
+54323 shun
+54324 shush
+54325 shut
+54326 shy
+54331 siamese
+54332 siberian
+54333 sibling
+54334 siding
+54335 sierra
+54336 siesta
+54341 sift
+54342 sighing
+54343 silenced
+54344 silencer
+54345 silent
+54346 silica
+54351 silicon
+54352 silk
+54353 silliness
+54354 silly
+54355 silo
+54356 silt
+54361 silver
+54362 similarly
+54363 simile
+54364 simmering
+54365 simple
+54366 simplify
+54411 simply
+54412 sincere
+54413 sincerity
+54414 singer
+54415 singing
+54416 single
+54421 singular
+54422 sinister
+54423 sinless
+54424 sinner
+54425 sinuous
+54426 sip
+54431 siren
+54432 sister
+54433 sitcom
+54434 sitter
+54435 sitting
+54436 situated
+54441 situation
+54442 sixfold
+54443 sixteen
+54444 sixth
+54445 sixties
+54446 sixtieth
+54451 sixtyfold
+54452 sizable
+54453 sizably
+54454 size
+54455 sizing
+54456 sizzle
+54461 sizzling
+54462 skater
+54463 skating
+54464 skedaddle
+54465 skeletal
+54466 skeleton
+54511 skeptic
+54512 sketch
+54513 skewed
+54514 skewer
+54515 skid
+54516 skied
+54521 skier
+54522 skies
+54523 skiing
+54524 skilled
+54525 skillet
+54526 skillful
+54531 skimmed
+54532 skimmer
+54533 skimming
+54534 skimpily
+54535 skincare
+54536 skinhead
+54541 skinless
+54542 skinning
+54543 skinny
+54544 skintight
+54545 skipper
+54546 skipping
+54551 skirmish
+54552 skirt
+54553 skittle
+54554 skydiver
+54555 skylight
+54556 skyline
+54561 skype
+54562 skyrocket
+54563 skyward
+54564 slab
+54565 slacked
+54566 slacker
+54611 slacking
+54612 slackness
+54613 slacks
+54614 slain
+54615 slam
+54616 slander
+54621 slang
+54622 slapping
+54623 slapstick
+54624 slashed
+54625 slashing
+54626 slate
+54631 slather
+54632 slaw
+54633 sled
+54634 sleek
+54635 sleep
+54636 sleet
+54641 sleeve
+54642 slept
+54643 sliceable
+54644 sliced
+54645 slicer
+54646 slicing
+54651 slick
+54652 slider
+54653 slideshow
+54654 sliding
+54655 slighted
+54656 slighting
+54661 slightly
+54662 slimness
+54663 slimy
+54664 slinging
+54665 slingshot
+54666 slinky
+55111 slip
+55112 slit
+55113 sliver
+55114 slobbery
+55115 slogan
+55116 sloped
+55121 sloping
+55122 sloppily
+55123 sloppy
+55124 slot
+55125 slouching
+55126 slouchy
+55131 sludge
+55132 slug
+55133 slum
+55134 slurp
+55135 slush
+55136 sly
+55141 small
+55142 smartly
+55143 smartness
+55144 smasher
+55145 smashing
+55146 smashup
+55151 smell
+55152 smelting
+55153 smile
+55154 smilingly
+55155 smirk
+55156 smite
+55161 smith
+55162 smitten
+55163 smock
+55164 smog
+55165 smoked
+55166 smokeless
+55211 smokiness
+55212 smoking
+55213 smoky
+55214 smolder
+55215 smooth
+55216 smother
+55221 smudge
+55222 smudgy
+55223 smuggler
+55224 smuggling
+55225 smugly
+55226 smugness
+55231 snack
+55232 snagged
+55233 snaking
+55234 snap
+55235 snare
+55236 snarl
+55241 snazzy
+55242 sneak
+55243 sneer
+55244 sneeze
+55245 sneezing
+55246 snide
+55251 sniff
+55252 snippet
+55253 snipping
+55254 snitch
+55255 snooper
+55256 snooze
+55261 snore
+55262 snoring
+55263 snorkel
+55264 snort
+55265 snout
+55266 snowbird
+55311 snowboard
+55312 snowbound
+55313 snowcap
+55314 snowdrift
+55315 snowdrop
+55316 snowfall
+55321 snowfield
+55322 snowflake
+55323 snowiness
+55324 snowless
+55325 snowman
+55326 snowplow
+55331 snowshoe
+55332 snowstorm
+55333 snowsuit
+55334 snowy
+55335 snub
+55336 snuff
+55341 snuggle
+55342 snugly
+55343 snugness
+55344 speak
+55345 spearfish
+55346 spearhead
+55351 spearman
+55352 spearmint
+55353 species
+55354 specimen
+55355 specked
+55356 speckled
+55361 specks
+55362 spectacle
+55363 spectator
+55364 spectrum
+55365 speculate
+55366 speech
+55411 speed
+55412 spellbind
+55413 speller
+55414 spelling
+55415 spendable
+55416 spender
+55421 spending
+55422 spent
+55423 spew
+55424 sphere
+55425 spherical
+55426 sphinx
+55431 spider
+55432 spied
+55433 spiffy
+55434 spill
+55435 spilt
+55436 spinach
+55441 spinal
+55442 spindle
+55443 spinner
+55444 spinning
+55445 spinout
+55446 spinster
+55451 spiny
+55452 spiral
+55453 spirited
+55454 spiritism
+55455 spirits
+55456 spiritual
+55461 splashed
+55462 splashing
+55463 splashy
+55464 splatter
+55465 spleen
+55466 splendid
+55511 splendor
+55512 splice
+55513 splicing
+55514 splinter
+55515 splotchy
+55516 splurge
+55521 spoilage
+55522 spoiled
+55523 spoiler
+55524 spoiling
+55525 spoils
+55526 spoken
+55531 spokesman
+55532 sponge
+55533 spongy
+55534 sponsor
+55535 spoof
+55536 spookily
+55541 spooky
+55542 spool
+55543 spoon
+55544 spore
+55545 sporting
+55546 sports
+55551 sporty
+55552 spotless
+55553 spotlight
+55554 spotted
+55555 spotter
+55556 spotting
+55561 spotty
+55562 spousal
+55563 spouse
+55564 spout
+55565 sprain
+55566 sprang
+55611 sprawl
+55612 spray
+55613 spree
+55614 sprig
+55615 spring
+55616 sprinkled
+55621 sprinkler
+55622 sprint
+55623 sprite
+55624 sprout
+55625 spruce
+55626 sprung
+55631 spry
+55632 spud
+55633 spur
+55634 sputter
+55635 spyglass
+55636 squabble
+55641 squad
+55642 squall
+55643 squander
+55644 squash
+55645 squatted
+55646 squatter
+55651 squatting
+55652 squeak
+55653 squealer
+55654 squealing
+55655 squeamish
+55656 squeegee
+55661 squeeze
+55662 squeezing
+55663 squid
+55664 squiggle
+55665 squiggly
+55666 squint
+56111 squire
+56112 squirt
+56113 squishier
+56114 squishy
+56115 stability
+56116 stabilize
+56121 stable
+56122 stack
+56123 stadium
+56124 staff
+56125 stage
+56126 staging
+56131 stagnant
+56132 stagnate
+56133 stainable
+56134 stained
+56135 staining
+56136 stainless
+56141 stalemate
+56142 staleness
+56143 stalling
+56144 stallion
+56145 stamina
+56146 stammer
+56151 stamp
+56152 stand
+56153 stank
+56154 staple
+56155 stapling
+56156 starboard
+56161 starch
+56162 stardom
+56163 stardust
+56164 starfish
+56165 stargazer
+56166 staring
+56211 stark
+56212 starless
+56213 starlet
+56214 starlight
+56215 starlit
+56216 starring
+56221 starry
+56222 starship
+56223 starter
+56224 starting
+56225 startle
+56226 startling
+56231 startup
+56232 starved
+56233 starving
+56234 stash
+56235 state
+56236 static
+56241 statistic
+56242 statue
+56243 stature
+56244 status
+56245 statute
+56246 statutory
+56251 staunch
+56252 stays
+56253 steadfast
+56254 steadier
+56255 steadily
+56256 steadying
+56261 steam
+56262 steed
+56263 steep
+56264 steerable
+56265 steering
+56266 steersman
+56311 stegosaur
+56312 stellar
+56313 stem
+56314 stench
+56315 stencil
+56316 step
+56321 stereo
+56322 sterile
+56323 sterility
+56324 sterilize
+56325 sterling
+56326 sternness
+56331 sternum
+56332 stew
+56333 stick
+56334 stiffen
+56335 stiffly
+56336 stiffness
+56341 stifle
+56342 stifling
+56343 stillness
+56344 stilt
+56345 stimulant
+56346 stimulate
+56351 stimuli
+56352 stimulus
+56353 stinger
+56354 stingily
+56355 stinging
+56356 stingray
+56361 stingy
+56362 stinking
+56363 stinky
+56364 stipend
+56365 stipulate
+56366 stir
+56411 stitch
+56412 stock
+56413 stoic
+56414 stoke
+56415 stole
+56416 stomp
+56421 stonewall
+56422 stoneware
+56423 stonework
+56424 stoning
+56425 stony
+56426 stood
+56431 stooge
+56432 stool
+56433 stoop
+56434 stoplight
+56435 stoppable
+56436 stoppage
+56441 stopped
+56442 stopper
+56443 stopping
+56444 stopwatch
+56445 storable
+56446 storage
+56451 storeroom
+56452 storewide
+56453 storm
+56454 stout
+56455 stove
+56456 stowaway
+56461 stowing
+56462 straddle
+56463 straggler
+56464 strained
+56465 strainer
+56466 straining
+56511 strangely
+56512 stranger
+56513 strangle
+56514 strategic
+56515 strategy
+56516 stratus
+56521 straw
+56522 stray
+56523 streak
+56524 stream
+56525 street
+56526 strength
+56531 strenuous
+56532 strep
+56533 stress
+56534 stretch
+56535 strewn
+56536 stricken
+56541 strict
+56542 stride
+56543 strife
+56544 strike
+56545 striking
+56546 strive
+56551 striving
+56552 strobe
+56553 strode
+56554 stroller
+56555 strongbox
+56556 strongly
+56561 strongman
+56562 struck
+56563 structure
+56564 strudel
+56565 struggle
+56566 strum
+56611 strung
+56612 strut
+56613 stubbed
+56614 stubble
+56615 stubbly
+56616 stubborn
+56621 stucco
+56622 stuck
+56623 student
+56624 studied
+56625 studio
+56626 study
+56631 stuffed
+56632 stuffing
+56633 stuffy
+56634 stumble
+56635 stumbling
+56636 stump
+56641 stung
+56642 stunned
+56643 stunner
+56644 stunning
+56645 stunt
+56646 stupor
+56651 sturdily
+56652 sturdy
+56653 styling
+56654 stylishly
+56655 stylist
+56656 stylized
+56661 stylus
+56662 suave
+56663 subarctic
+56664 subatomic
+56665 subdivide
+56666 subdued
+61111 subduing
+61112 subfloor
+61113 subgroup
+61114 subheader
+61115 subject
+61116 sublease
+61121 sublet
+61122 sublevel
+61123 sublime
+61124 submarine
+61125 submerge
+61126 submersed
+61131 submitter
+61132 subpanel
+61133 subpar
+61134 subplot
+61135 subprime
+61136 subscribe
+61141 subscript
+61142 subsector
+61143 subside
+61144 subsiding
+61145 subsidize
+61146 subsidy
+61151 subsoil
+61152 subsonic
+61153 substance
+61154 subsystem
+61155 subtext
+61156 subtitle
+61161 subtly
+61162 subtotal
+61163 subtract
+61164 subtype
+61165 suburb
+61166 subway
+61211 subwoofer
+61212 subzero
+61213 succulent
+61214 such
+61215 suction
+61216 sudden
+61221 sudoku
+61222 suds
+61223 sufferer
+61224 suffering
+61225 suffice
+61226 suffix
+61231 suffocate
+61232 suffrage
+61233 sugar
+61234 suggest
+61235 suing
+61236 suitable
+61241 suitably
+61242 suitcase
+61243 suitor
+61244 sulfate
+61245 sulfide
+61246 sulfite
+61251 sulfur
+61252 sulk
+61253 sullen
+61254 sulphate
+61255 sulphuric
+61256 sultry
+61261 superbowl
+61262 superglue
+61263 superhero
+61264 superior
+61265 superjet
+61266 superman
+61311 supermom
+61312 supernova
+61313 supervise
+61314 supper
+61315 supplier
+61316 supply
+61321 support
+61322 supremacy
+61323 supreme
+61324 surcharge
+61325 surely
+61326 sureness
+61331 surface
+61332 surfacing
+61333 surfboard
+61334 surfer
+61335 surgery
+61336 surgical
+61341 surging
+61342 surname
+61343 surpass
+61344 surplus
+61345 surprise
+61346 surreal
+61351 surrender
+61352 surrogate
+61353 surround
+61354 survey
+61355 survival
+61356 survive
+61361 surviving
+61362 survivor
+61363 sushi
+61364 suspect
+61365 suspend
+61366 suspense
+61411 sustained
+61412 sustainer
+61413 swab
+61414 swaddling
+61415 swagger
+61416 swampland
+61421 swan
+61422 swapping
+61423 swarm
+61424 sway
+61425 swear
+61426 sweat
+61431 sweep
+61432 swell
+61433 swept
+61434 swerve
+61435 swifter
+61436 swiftly
+61441 swiftness
+61442 swimmable
+61443 swimmer
+61444 swimming
+61445 swimsuit
+61446 swimwear
+61451 swinger
+61452 swinging
+61453 swipe
+61454 swirl
+61455 switch
+61456 swivel
+61461 swizzle
+61462 swooned
+61463 swoop
+61464 swoosh
+61465 swore
+61466 sworn
+61511 swung
+61512 sycamore
+61513 sympathy
+61514 symphonic
+61515 symphony
+61516 symptom
+61521 synapse
+61522 syndrome
+61523 synergy
+61524 synopses
+61525 synopsis
+61526 synthesis
+61531 synthetic
+61532 syrup
+61533 system
+61534 t-shirt
+61535 tabasco
+61536 tabby
+61541 tableful
+61542 tables
+61543 tablet
+61544 tableware
+61545 tabloid
+61546 tackiness
+61551 tacking
+61552 tackle
+61553 tackling
+61554 tacky
+61555 taco
+61556 tactful
+61561 tactical
+61562 tactics
+61563 tactile
+61564 tactless
+61565 tadpole
+61566 taekwondo
+61611 tag
+61612 tainted
+61613 take
+61614 taking
+61615 talcum
+61616 talisman
+61621 tall
+61622 talon
+61623 tamale
+61624 tameness
+61625 tamer
+61626 tamper
+61631 tank
+61632 tanned
+61633 tannery
+61634 tanning
+61635 tantrum
+61636 tapeless
+61641 tapered
+61642 tapering
+61643 tapestry
+61644 tapioca
+61645 tapping
+61646 taps
+61651 tarantula
+61652 target
+61653 tarmac
+61654 tarnish
+61655 tarot
+61656 tartar
+61661 tartly
+61662 tartness
+61663 task
+61664 tassel
+61665 taste
+61666 tastiness
+62111 tasting
+62112 tasty
+62113 tattered
+62114 tattle
+62115 tattling
+62116 tattoo
+62121 taunt
+62122 tavern
+62123 thank
+62124 that
+62125 thaw
+62126 theater
+62131 theatrics
+62132 thee
+62133 theft
+62134 theme
+62135 theology
+62136 theorize
+62141 thermal
+62142 thermos
+62143 thesaurus
+62144 these
+62145 thesis
+62146 thespian
+62151 thicken
+62152 thicket
+62153 thickness
+62154 thieving
+62155 thievish
+62156 thigh
+62161 thimble
+62162 thing
+62163 think
+62164 thinly
+62165 thinner
+62166 thinness
+62211 thinning
+62212 thirstily
+62213 thirsting
+62214 thirsty
+62215 thirteen
+62216 thirty
+62221 thong
+62222 thorn
+62223 those
+62224 thousand
+62225 thrash
+62226 thread
+62231 threaten
+62232 threefold
+62233 thrift
+62234 thrill
+62235 thrive
+62236 thriving
+62241 throat
+62242 throbbing
+62243 throng
+62244 throttle
+62245 throwaway
+62246 throwback
+62251 thrower
+62252 throwing
+62253 thud
+62254 thumb
+62255 thumping
+62256 thursday
+62261 thus
+62262 thwarting
+62263 thyself
+62264 tiara
+62265 tibia
+62266 tidal
+62311 tidbit
+62312 tidiness
+62313 tidings
+62314 tidy
+62315 tiger
+62316 tighten
+62321 tightly
+62322 tightness
+62323 tightrope
+62324 tightwad
+62325 tigress
+62326 tile
+62331 tiling
+62332 till
+62333 tilt
+62334 timid
+62335 timing
+62336 timothy
+62341 tinderbox
+62342 tinfoil
+62343 tingle
+62344 tingling
+62345 tingly
+62346 tinker
+62351 tinkling
+62352 tinsel
+62353 tinsmith
+62354 tint
+62355 tinwork
+62356 tiny
+62361 tipoff
+62362 tipped
+62363 tipper
+62364 tipping
+62365 tiptoeing
+62366 tiptop
+62411 tiring
+62412 tissue
+62413 trace
+62414 tracing
+62415 track
+62416 traction
+62421 tractor
+62422 trade
+62423 trading
+62424 tradition
+62425 traffic
+62426 tragedy
+62431 trailing
+62432 trailside
+62433 train
+62434 traitor
+62435 trance
+62436 tranquil
+62441 transfer
+62442 transform
+62443 translate
+62444 transpire
+62445 transport
+62446 transpose
+62451 trapdoor
+62452 trapeze
+62453 trapezoid
+62454 trapped
+62455 trapper
+62456 trapping
+62461 traps
+62462 trash
+62463 travel
+62464 traverse
+62465 travesty
+62466 tray
+62511 treachery
+62512 treading
+62513 treadmill
+62514 treason
+62515 treat
+62516 treble
+62521 tree
+62522 trekker
+62523 tremble
+62524 trembling
+62525 tremor
+62526 trench
+62531 trend
+62532 trespass
+62533 triage
+62534 trial
+62535 triangle
+62536 tribesman
+62541 tribunal
+62542 tribune
+62543 tributary
+62544 tribute
+62545 triceps
+62546 trickery
+62551 trickily
+62552 tricking
+62553 trickle
+62554 trickster
+62555 tricky
+62556 tricolor
+62561 tricycle
+62562 trident
+62563 tried
+62564 trifle
+62565 trifocals
+62566 trillion
+62611 trilogy
+62612 trimester
+62613 trimmer
+62614 trimming
+62615 trimness
+62616 trinity
+62621 trio
+62622 tripod
+62623 tripping
+62624 triumph
+62625 trivial
+62626 trodden
+62631 trolling
+62632 trombone
+62633 trophy
+62634 tropical
+62635 tropics
+62636 trouble
+62641 troubling
+62642 trough
+62643 trousers
+62644 trout
+62645 trowel
+62646 truce
+62651 truck
+62652 truffle
+62653 trump
+62654 trunks
+62655 trustable
+62656 trustee
+62661 trustful
+62662 trusting
+62663 trustless
+62664 truth
+62665 try
+62666 tubby
+63111 tubeless
+63112 tubular
+63113 tucking
+63114 tuesday
+63115 tug
+63116 tuition
+63121 tulip
+63122 tumble
+63123 tumbling
+63124 tummy
+63125 turban
+63126 turbine
+63131 turbofan
+63132 turbojet
+63133 turbulent
+63134 turf
+63135 turkey
+63136 turmoil
+63141 turret
+63142 turtle
+63143 tusk
+63144 tutor
+63145 tutu
+63146 tux
+63151 tweak
+63152 tweed
+63153 tweet
+63154 tweezers
+63155 twelve
+63156 twentieth
+63161 twenty
+63162 twerp
+63163 twice
+63164 twiddle
+63165 twiddling
+63166 twig
+63211 twilight
+63212 twine
+63213 twins
+63214 twirl
+63215 twistable
+63216 twisted
+63221 twister
+63222 twisting
+63223 twisty
+63224 twitch
+63225 twitter
+63226 tycoon
+63231 tying
+63232 tyke
+63233 udder
+63234 ultimate
+63235 ultimatum
+63236 ultra
+63241 umbilical
+63242 umbrella
+63243 umpire
+63244 unabashed
+63245 unable
+63246 unadorned
+63251 unadvised
+63252 unafraid
+63253 unaired
+63254 unaligned
+63255 unaltered
+63256 unarmored
+63261 unashamed
+63262 unaudited
+63263 unawake
+63264 unaware
+63265 unbaked
+63266 unbalance
+63311 unbeaten
+63312 unbend
+63313 unbent
+63314 unbiased
+63315 unbitten
+63316 unblended
+63321 unblessed
+63322 unblock
+63323 unbolted
+63324 unbounded
+63325 unboxed
+63326 unbraided
+63331 unbridle
+63332 unbroken
+63333 unbuckled
+63334 unbundle
+63335 unburned
+63336 unbutton
+63341 uncanny
+63342 uncapped
+63343 uncaring
+63344 uncertain
+63345 unchain
+63346 unchanged
+63351 uncharted
+63352 uncheck
+63353 uncivil
+63354 unclad
+63355 unclaimed
+63356 unclamped
+63361 unclasp
+63362 uncle
+63363 unclip
+63364 uncloak
+63365 unclog
+63366 unclothed
+63411 uncoated
+63412 uncoiled
+63413 uncolored
+63414 uncombed
+63415 uncommon
+63416 uncooked
+63421 uncork
+63422 uncorrupt
+63423 uncounted
+63424 uncouple
+63425 uncouth
+63426 uncover
+63431 uncross
+63432 uncrown
+63433 uncrushed
+63434 uncured
+63435 uncurious
+63436 uncurled
+63441 uncut
+63442 undamaged
+63443 undated
+63444 undaunted
+63445 undead
+63446 undecided
+63451 undefined
+63452 underage
+63453 underarm
+63454 undercoat
+63455 undercook
+63456 undercut
+63461 underdog
+63462 underdone
+63463 underfed
+63464 underfeed
+63465 underfoot
+63466 undergo
+63511 undergrad
+63512 underhand
+63513 underline
+63514 underling
+63515 undermine
+63516 undermost
+63521 underpaid
+63522 underpass
+63523 underpay
+63524 underrate
+63525 undertake
+63526 undertone
+63531 undertook
+63532 undertow
+63533 underuse
+63534 underwear
+63535 underwent
+63536 underwire
+63541 undesired
+63542 undiluted
+63543 undivided
+63544 undocked
+63545 undoing
+63546 undone
+63551 undrafted
+63552 undress
+63553 undrilled
+63554 undusted
+63555 undying
+63556 unearned
+63561 unearth
+63562 unease
+63563 uneasily
+63564 uneasy
+63565 uneatable
+63566 uneaten
+63611 unedited
+63612 unelected
+63613 unending
+63614 unengaged
+63615 unenvied
+63616 unequal
+63621 unethical
+63622 uneven
+63623 unexpired
+63624 unexposed
+63625 unfailing
+63626 unfair
+63631 unfasten
+63632 unfazed
+63633 unfeeling
+63634 unfiled
+63635 unfilled
+63636 unfitted
+63641 unfitting
+63642 unfixable
+63643 unfixed
+63644 unflawed
+63645 unfocused
+63646 unfold
+63651 unfounded
+63652 unframed
+63653 unfreeze
+63654 unfrosted
+63655 unfrozen
+63656 unfunded
+63661 unglazed
+63662 ungloved
+63663 unglue
+63664 ungodly
+63665 ungraded
+63666 ungreased
+64111 unguarded
+64112 unguided
+64113 unhappily
+64114 unhappy
+64115 unharmed
+64116 unhealthy
+64121 unheard
+64122 unhearing
+64123 unheated
+64124 unhelpful
+64125 unhidden
+64126 unhinge
+64131 unhitched
+64132 unholy
+64133 unhook
+64134 unicorn
+64135 unicycle
+64136 unified
+64141 unifier
+64142 uniformed
+64143 uniformly
+64144 unify
+64145 unimpeded
+64146 uninjured
+64151 uninstall
+64152 uninsured
+64153 uninvited
+64154 union
+64155 uniquely
+64156 unisexual
+64161 unison
+64162 unissued
+64163 unit
+64164 universal
+64165 universe
+64166 unjustly
+64211 unkempt
+64212 unkind
+64213 unknotted
+64214 unknowing
+64215 unknown
+64216 unlaced
+64221 unlatch
+64222 unlawful
+64223 unleaded
+64224 unlearned
+64225 unleash
+64226 unless
+64231 unleveled
+64232 unlighted
+64233 unlikable
+64234 unlimited
+64235 unlined
+64236 unlinked
+64241 unlisted
+64242 unlit
+64243 unlivable
+64244 unloaded
+64245 unloader
+64246 unlocked
+64251 unlocking
+64252 unlovable
+64253 unloved
+64254 unlovely
+64255 unloving
+64256 unluckily
+64261 unlucky
+64262 unmade
+64263 unmanaged
+64264 unmanned
+64265 unmapped
+64266 unmarked
+64311 unmasked
+64312 unmasking
+64313 unmatched
+64314 unmindful
+64315 unmixable
+64316 unmixed
+64321 unmolded
+64322 unmoral
+64323 unmovable
+64324 unmoved
+64325 unmoving
+64326 unnamable
+64331 unnamed
+64332 unnatural
+64333 unneeded
+64334 unnerve
+64335 unnerving
+64336 unnoticed
+64341 unopened
+64342 unopposed
+64343 unpack
+64344 unpadded
+64345 unpaid
+64346 unpainted
+64351 unpaired
+64352 unpaved
+64353 unpeeled
+64354 unpicked
+64355 unpiloted
+64356 unpinned
+64361 unplanned
+64362 unplanted
+64363 unpleased
+64364 unpledged
+64365 unplowed
+64366 unplug
+64411 unpopular
+64412 unproven
+64413 unquote
+64414 unranked
+64415 unrated
+64416 unraveled
+64421 unreached
+64422 unread
+64423 unreal
+64424 unreeling
+64425 unrefined
+64426 unrelated
+64431 unrented
+64432 unrest
+64433 unretired
+64434 unrevised
+64435 unrigged
+64436 unripe
+64441 unrivaled
+64442 unroasted
+64443 unrobed
+64444 unroll
+64445 unruffled
+64446 unruly
+64451 unrushed
+64452 unsaddle
+64453 unsafe
+64454 unsaid
+64455 unsalted
+64456 unsaved
+64461 unsavory
+64462 unscathed
+64463 unscented
+64464 unscrew
+64465 unsealed
+64466 unseated
+64511 unsecured
+64512 unseeing
+64513 unseemly
+64514 unseen
+64515 unselect
+64516 unselfish
+64521 unsent
+64522 unsettled
+64523 unshackle
+64524 unshaken
+64525 unshaved
+64526 unshaven
+64531 unsheathe
+64532 unshipped
+64533 unsightly
+64534 unsigned
+64535 unskilled
+64536 unsliced
+64541 unsmooth
+64542 unsnap
+64543 unsocial
+64544 unsoiled
+64545 unsold
+64546 unsolved
+64551 unsorted
+64552 unspoiled
+64553 unspoken
+64554 unstable
+64555 unstaffed
+64556 unstamped
+64561 unsteady
+64562 unsterile
+64563 unstirred
+64564 unstitch
+64565 unstopped
+64566 unstuck
+64611 unstuffed
+64612 unstylish
+64613 unsubtle
+64614 unsubtly
+64615 unsuited
+64616 unsure
+64621 unsworn
+64622 untagged
+64623 untainted
+64624 untaken
+64625 untamed
+64626 untangled
+64631 untapped
+64632 untaxed
+64633 unthawed
+64634 unthread
+64635 untidy
+64636 untie
+64641 until
+64642 untimed
+64643 untimely
+64644 untitled
+64645 untoasted
+64646 untold
+64651 untouched
+64652 untracked
+64653 untrained
+64654 untreated
+64655 untried
+64656 untrimmed
+64661 untrue
+64662 untruth
+64663 unturned
+64664 untwist
+64665 untying
+64666 unusable
+65111 unused
+65112 unusual
+65113 unvalued
+65114 unvaried
+65115 unvarying
+65116 unveiled
+65121 unveiling
+65122 unvented
+65123 unviable
+65124 unvisited
+65125 unvocal
+65126 unwanted
+65131 unwarlike
+65132 unwary
+65133 unwashed
+65134 unwatched
+65135 unweave
+65136 unwed
+65141 unwelcome
+65142 unwell
+65143 unwieldy
+65144 unwilling
+65145 unwind
+65146 unwired
+65151 unwitting
+65152 unwomanly
+65153 unworldly
+65154 unworn
+65155 unworried
+65156 unworthy
+65161 unwound
+65162 unwoven
+65163 unwrapped
+65164 unwritten
+65165 unzip
+65166 upbeat
+65211 upchuck
+65212 upcoming
+65213 upcountry
+65214 update
+65215 upfront
+65216 upgrade
+65221 upheaval
+65222 upheld
+65223 uphill
+65224 uphold
+65225 uplifted
+65226 uplifting
+65231 upload
+65232 upon
+65233 upper
+65234 upright
+65235 uprising
+65236 upriver
+65241 uproar
+65242 uproot
+65243 upscale
+65244 upside
+65245 upstage
+65246 upstairs
+65251 upstart
+65252 upstate
+65253 upstream
+65254 upstroke
+65255 upswing
+65256 uptake
+65261 uptight
+65262 uptown
+65263 upturned
+65264 upward
+65265 upwind
+65266 uranium
+65311 urban
+65312 urchin
+65313 urethane
+65314 urgency
+65315 urgent
+65316 urging
+65321 urologist
+65322 urology
+65323 usable
+65324 usage
+65325 useable
+65326 used
+65331 uselessly
+65332 user
+65333 usher
+65334 usual
+65335 utensil
+65336 utility
+65341 utilize
+65342 utmost
+65343 utopia
+65344 utter
+65345 vacancy
+65346 vacant
+65351 vacate
+65352 vacation
+65353 vagabond
+65354 vagrancy
+65355 vagrantly
+65356 vaguely
+65361 vagueness
+65362 valiant
+65363 valid
+65364 valium
+65365 valley
+65366 valuables
+65411 value
+65412 vanilla
+65413 vanish
+65414 vanity
+65415 vanquish
+65416 vantage
+65421 vaporizer
+65422 variable
+65423 variably
+65424 varied
+65425 variety
+65426 various
+65431 varmint
+65432 varnish
+65433 varsity
+65434 varying
+65435 vascular
+65436 vaseline
+65441 vastly
+65442 vastness
+65443 veal
+65444 vegan
+65445 veggie
+65446 vehicular
+65451 velcro
+65452 velocity
+65453 velvet
+65454 vendetta
+65455 vending
+65456 vendor
+65461 veneering
+65462 vengeful
+65463 venomous
+65464 ventricle
+65465 venture
+65466 venue
+65511 venus
+65512 verbalize
+65513 verbally
+65514 verbose
+65515 verdict
+65516 verify
+65521 verse
+65522 version
+65523 versus
+65524 vertebrae
+65525 vertical
+65526 vertigo
+65531 very
+65532 vessel
+65533 vest
+65534 veteran
+65535 veto
+65536 vexingly
+65541 viability
+65542 viable
+65543 vibes
+65544 vice
+65545 vicinity
+65546 victory
+65551 video
+65552 viewable
+65553 viewer
+65554 viewing
+65555 viewless
+65556 viewpoint
+65561 vigorous
+65562 village
+65563 villain
+65564 vindicate
+65565 vineyard
+65566 vintage
+65611 violate
+65612 violation
+65613 violator
+65614 violet
+65615 violin
+65616 viper
+65621 viral
+65622 virtual
+65623 virtuous
+65624 virus
+65625 visa
+65626 viscosity
+65631 viscous
+65632 viselike
+65633 visible
+65634 visibly
+65635 vision
+65636 visiting
+65641 visitor
+65642 visor
+65643 vista
+65644 vitality
+65645 vitalize
+65646 vitally
+65651 vitamins
+65652 vivacious
+65653 vividly
+65654 vividness
+65655 vixen
+65656 vocalist
+65661 vocalize
+65662 vocally
+65663 vocation
+65664 voice
+65665 voicing
+65666 void
+66111 volatile
+66112 volley
+66113 voltage
+66114 volumes
+66115 voter
+66116 voting
+66121 voucher
+66122 vowed
+66123 vowel
+66124 voyage
+66125 wackiness
+66126 wad
+66131 wafer
+66132 waffle
+66133 waged
+66134 wager
+66135 wages
+66136 waggle
+66141 wagon
+66142 wake
+66143 waking
+66144 walk
+66145 walmart
+66146 walnut
+66151 walrus
+66152 waltz
+66153 wand
+66154 wannabe
+66155 wanted
+66156 wanting
+66161 wasabi
+66162 washable
+66163 washbasin
+66164 washboard
+66165 washbowl
+66166 washcloth
+66211 washday
+66212 washed
+66213 washer
+66214 washhouse
+66215 washing
+66216 washout
+66221 washroom
+66222 washstand
+66223 washtub
+66224 wasp
+66225 wasting
+66226 watch
+66231 water
+66232 waviness
+66233 waving
+66234 wavy
+66235 whacking
+66236 whacky
+66241 wham
+66242 wharf
+66243 wheat
+66244 whenever
+66245 whiff
+66246 whimsical
+66251 whinny
+66252 whiny
+66253 whisking
+66254 whoever
+66255 whole
+66256 whomever
+66261 whoopee
+66262 whooping
+66263 whoops
+66264 why
+66265 wick
+66266 widely
+66311 widen
+66312 widget
+66313 widow
+66314 width
+66315 wieldable
+66316 wielder
+66321 wife
+66322 wifi
+66323 wikipedia
+66324 wildcard
+66325 wildcat
+66326 wilder
+66331 wildfire
+66332 wildfowl
+66333 wildland
+66334 wildlife
+66335 wildly
+66336 wildness
+66341 willed
+66342 willfully
+66343 willing
+66344 willow
+66345 willpower
+66346 wilt
+66351 wimp
+66352 wince
+66353 wincing
+66354 wind
+66355 wing
+66356 winking
+66361 winner
+66362 winnings
+66363 winter
+66364 wipe
+66365 wired
+66366 wireless
+66411 wiring
+66412 wiry
+66413 wisdom
+66414 wise
+66415 wish
+66416 wisplike
+66421 wispy
+66422 wistful
+66423 wizard
+66424 wobble
+66425 wobbling
+66426 wobbly
+66431 wok
+66432 wolf
+66433 wolverine
+66434 womanhood
+66435 womankind
+66436 womanless
+66441 womanlike
+66442 womanly
+66443 womb
+66444 woof
+66445 wooing
+66446 wool
+66451 woozy
+66452 word
+66453 work
+66454 worried
+66455 worrier
+66456 worrisome
+66461 worry
+66462 worsening
+66463 worshiper
+66464 worst
+66465 wound
+66466 woven
+66511 wow
+66512 wrangle
+66513 wrath
+66514 wreath
+66515 wreckage
+66516 wrecker
+66521 wrecking
+66522 wrench
+66523 wriggle
+66524 wriggly
+66525 wrinkle
+66526 wrinkly
+66531 wrist
+66532 writing
+66533 written
+66534 wrongdoer
+66535 wronged
+66536 wrongful
+66541 wrongly
+66542 wrongness
+66543 wrought
+66544 xbox
+66545 xerox
+66546 yahoo
+66551 yam
+66552 yanking
+66553 yapping
+66554 yard
+66555 yarn
+66556 yeah
+66561 yearbook
+66562 yearling
+66563 yearly
+66564 yearning
+66565 yeast
+66566 yelling
+66611 yelp
+66612 yen
+66613 yesterday
+66614 yiddish
+66615 yield
+66616 yin
+66621 yippee
+66622 yo-yo
+66623 yodel
+66624 yoga
+66625 yogurt
+66626 yonder
+66631 yoyo
+66632 yummy
+66633 zap
+66634 zealous
+66635 zebra
+66636 zen
+66641 zeppelin
+66642 zero
+66643 zestfully
+66644 zesty
+66645 zigzagged
+66646 zipfile
+66651 zipping
+66652 zippy
+66653 zips
+66654 zit
+66655 zodiac
+66656 zombie
+66661 zone
+66662 zoning
+66663 zookeeper
+66664 zoologist
+66665 zoology
+66666 zoom
diff --git a/apps/desktop/src/AboutPanel.tsx b/apps/desktop/src/AboutPanel.tsx
index 1e91a36..b51dcb4 100644
--- a/apps/desktop/src/AboutPanel.tsx
+++ b/apps/desktop/src/AboutPanel.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
-import { dateText, device, errorText, updates, type About, type UpdateInfo, type UpdateProgress } from "./api";
+import { dateText, device, errorText, openLink, updates, type About, type UpdateInfo, type UpdateProgress } from "./api";
import { Licenses } from "./Licenses";
import { Logo } from "./Logo";
import { mobileOs, onIos } from "./mobile/useMobile";
@@ -25,6 +25,10 @@ const PACKAGE_NAMES: Record, string> = {
const mib = (n: number) => `${(n / 1048576).toFixed(1)} MiB`;
+/** Where people read about MorphGate, and where its code is. */
+const DOCS = "https://morphgate.com/docs";
+const SOURCE = "https://github.com/Vallic/MorphGate";
+
/** Version, and updates from MorphGate's releases. */
export function AboutPanel({ update, onUpdate, onAutoCheckChanged, onClose }: Props) {
const [about, setAbout] = useState(null);
@@ -92,9 +96,17 @@ export function AboutPanel({ update, onUpdate, onAutoCheckChanged, onClose }: Pr
SSH client with an end-to-end encrypted vault. Free software under the Mozilla Public License 2.0 · morphgate.com
- setLicenses(true)}>
- Open-source licenses
-
+
+ void openLink(DOCS).catch((e) => setError(errorText(e)))}>
+ Documentation ↗
+
+ void openLink(SOURCE).catch((e) => setError(errorText(e)))}>
+ Source code on GitHub ↗
+
+ setLicenses(true)}>
+ Open-source licenses
+
+
Updates
{mobileOs ? (
@@ -131,7 +143,8 @@ export function AboutPanel({ update, onUpdate, onAutoCheckChanged, onClose }: Pr
-
+ {/* Install takes the update the last finished check found. */}
+
Install and restart
diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx
index 12afc0f..1b5e951 100644
--- a/apps/desktop/src/App.tsx
+++ b/apps/desktop/src/App.tsx
@@ -10,6 +10,7 @@ import {
hostCas as hostCasApi,
keys as keysApi,
snippets as snippetsApi,
+ cloud as cloudApi,
forwards as forwardsApi,
device as deviceApi,
sync as syncApi,
@@ -43,6 +44,7 @@ import {
type TerminalSettings,
type VaultTheme,
type SecurityKeyTouch,
+ type Link,
type ReceivedLink,
} from "./api";
import { AppearancePanel } from "./AppearancePanel";
@@ -53,6 +55,7 @@ import { HostEditor } from "./HostEditor";
import { GroupEditor, IdentityEditor } from "./Identities";
import { HostCaPanel } from "./HostCaPanel";
import { HostKeyChangedDialog, HostKeyDialog } from "./HostKeyDialog";
+import { SnippetCheckDialog } from "./SnippetCheck";
import { RouteBar } from "./RouteBar";
import { addPane, dividers, holds, moveDivider, panes, rects, removePane, type Divider, type Rect, type Split } from "./splits";
import { SessionLogsPanel } from "./SessionLogs";
@@ -66,11 +69,11 @@ import { PasswordDialog } from "./PasswordDialog";
import { CloudPage } from "./CloudPage";
import { ImportHostsPage } from "./ImportHosts";
import { ImportSnippetsPage } from "./ImportSnippets";
-import { GroupPage, GroupsPage, HostsPage, KeysPage, SnippetsPage, TunnelsPage, VpnsPage } from "./Lists";
+import { GroupPage, GroupsPage, HostsPage, KeysPage, SnippetsPage, TunnelsPage, VpnsPage, type KeysTab } from "./Lists";
import { MoveContext } from "./Move";
import { Sidebar, type LiveState, type PageKind } from "./Sidebar";
import { SnippetPanel } from "./SnippetPanel";
-import { SyncOffer, SyncPanel } from "./SyncPanel";
+import { SyncOffer, syncOfferTime, SyncPanel } from "./SyncPanel";
import { VpnPanel } from "./VpnPanel";
import { SecurityPanel } from "./SecurityPanel";
import { TeamsPanel } from "./TeamsPanel";
@@ -93,8 +96,6 @@ export function App() {
const loadTermSettings = useCallback(() => void appearanceApi.settings().then(setTermSettings, () => {}), []);
useEffect(loadTermSettings, [loadTermSettings]);
const appTheme = useAppTheme(termSettings?.appTheme ?? "dark");
- // A vault created just now (not joined through sync): offer sync once.
- const [created, setCreated] = useState(false);
useEffect(() => {
vault.status().then(setStatus, (e) => setFatal(errorText(e)));
@@ -102,13 +103,13 @@ export function App() {
// Signed out by its sync account, or disconnected and cleared: back to the start, saying why.
const [erased, setErased] = useState(null);
useEffect(() => {
- const off = listen("device-erased", (e) => (setErased(e.payload), setCreated(false), setStatus("uninitialized"), loadTermSettings()));
+ const off = listen("device-erased", (e) => (setErased(e.payload), setStatus("uninitialized"), loadTermSettings()));
return () => void off.then((f) => f());
}, [loadTermSettings]);
if (fatal) return ;
if (status === null) return
;
- if (status === "uninitialized") return (setErased(null), setCreated(fresh), setStatus("unlocked"))} />;
+ if (status === "uninitialized") return (setErased(null), fresh ? syncOfferTime.schedule() : syncOfferTime.forget(), setStatus("unlocked"))} />;
if (status === "locked") return setStatus("unlocked")} />;
return (
);
}
@@ -154,7 +154,8 @@ type View =
| { kind: "key"; id?: string }
| { kind: "snippet"; id?: string }
| { kind: "forward"; id?: string }
- | { kind: "sync" }
+ /** `totp`: straight to turning on two-step verification (teams wait for it). */
+ | { kind: "sync"; totp?: boolean }
| { kind: "security" }
| { kind: "about" }
| { kind: "teams" }
@@ -167,11 +168,12 @@ type View =
/** Hosts from OpenSSH's configuration. */
| { kind: "importHosts" }
| { kind: "importSnippets" }
- | { kind: "cloud" }
+ | { kind: "cloud"; add?: "feed" }
| { kind: "groups" }
/** One group and everything in it. */
| { kind: "groupPage"; id: string }
- | { kind: "keys" }
+ /** `tab`: Keys (the default), Identities or Host CAs. */
+ | { kind: "keys"; tab?: KeysTab }
| { kind: "snippets" }
| { kind: "forwards" }
| { kind: "vpns" };
@@ -179,6 +181,10 @@ type View =
/** Views that fill the workspace: with no session open, the tab bar makes room for them too. */
const PAGES = new Set(["hosts", "importHosts", "importSnippets", "cloud", "groups", "groupPage", "keys", "snippets", "forwards", "vpns", "logs", "teams", "sync"]);
+/** Pages opened from another page (Import from Snippets, a group from Groups): closing one
+ * returns to that page, where a page opened from the sidebar replaces the one shown. */
+const SUBPAGES = new Set(["importHosts", "importSnippets", "cloud", "groupPage", "logs"]);
+
/** Editors: opening one from another keeps the first one's way back. */
const EDITORS = new Set(["host", "identity", "group", "key", "snippet", "forward", "vpn", "hostCa"]);
@@ -240,11 +246,9 @@ interface WorkspaceProps {
onSettingsChanged: () => void;
/** The app theme in effect. */
appTheme: "dark" | "light";
- /** The vault was just created: offer to turn sync on. */
- offerSync: boolean;
}
-function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings, appTheme, offerSync }: WorkspaceProps) {
+function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings, appTheme }: WorkspaceProps) {
const [hostList, setHosts] = useState([]);
const [identityList, setIdentities] = useState([]);
const [hostCaList, setHostCas] = useState([]);
@@ -285,8 +289,9 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
return open({ kind: "teams" });
case "invite":
if (!syncStatus?.enabled || syncStatus.signedOut) {
+ setWaitingInvite(link);
setNotice(
- `This is an invite to a team${link.server ? ` on ${new URL(link.server).host}` : ""}. Turn on sync with the invited address first, then open the link again.`,
+ `This is an invite to a team${link.server ? ` on ${new URL(link.server).host}` : ""}. Turn on sync with the invited address: Teams opens with the invite once it's on.`,
);
return open({ kind: "sync" });
}
@@ -300,13 +305,24 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
return open({ kind: mobile ? "connect" : "quick" });
}
};
+ const [syncStatus, setSyncStatus] = useState(null);
+ // Links are taken once sync's state is known: an invite needs it.
+ const statusKnown = syncStatus !== null;
useEffect(() => {
+ if (!statusKnown) return;
const take = () => void linksApi.take().then((all) => all.forEach((r) => openLink.current(r)), () => {});
take();
const unlisten = listen("links-pending", take);
return () => void unlisten.then((f) => f());
- }, []);
- const [syncStatus, setSyncStatus] = useState(null);
+ }, [statusKnown]);
+ // An invite that came while sync was off: Teams opens with it once sync is on.
+ const [waitingInvite, setWaitingInvite] = useState (null);
+ useEffect(() => {
+ if (!waitingInvite || !syncStatus?.enabled || syncStatus.signedOut) return;
+ setWaitingInvite(null);
+ setNotice("");
+ openLink.current({ link: waitingInvite, error: null });
+ }, [waitingInvite, syncStatus?.enabled, syncStatus?.signedOut]);
useEffect(() => {
if (orgList.length === 0) return setLockedOrgs(new Set());
@@ -331,21 +347,26 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
if (tabs.length === 0) setMobile(onPhone || narrow);
}, [narrow, tabs.length]);
const [view, setView] = useState({ kind: "quick" });
+ // Home, where the workspace opens and where closing the last page or tab lands: the hosts once
+ // there are any (on a phone, "quick" is the host list already), else quick connect.
+ const home = (): View => (!mobile && hostList.length > 0 ? { kind: "hosts" } : { kind: "quick" });
// The way back: closing a view returns to the page, editor or session it was opened from.
const [trail, setTrail] = useState([]);
const open = (v: View) => {
if (v.kind === "tab" || v.kind === "quick") setTrail([]);
else {
// An editor replaces an editor, a page a page: what was under the first stays the way back.
+ // A page's own pages (its import, a group) keep it as the way back.
const editor = (k: View["kind"]) => EDITORS.has(k);
const from = view.kind === "tab" || view.kind === "quick" ? null : editor(view.kind);
- if (view.kind !== "quick" && from !== editor(v.kind)) setTrail((t) => [...t, view].slice(-8));
+ if (view.kind !== "quick" && (from !== editor(v.kind) || SUBPAGES.has(v.kind))) setTrail((t) => [...t, view].slice(-8));
}
setView(v);
};
const close = () => {
const rest = [...trail];
- let next: View = { kind: "quick" };
+ // Closing home itself goes to quick connect.
+ let next: View = view.kind === "hosts" ? { kind: "quick" } : home();
while (rest.length) {
const v = rest.pop()!;
if (v.kind !== "tab" || tabs.some((t) => t.key === v.key)) {
@@ -361,11 +382,18 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
useEffect(() => {
if (linked && !["teams", "quick", "connect"].includes(view.kind)) setLinked(null);
}, [view.kind]);
- const [offering, setOffering] = useState(offerSync);
+ // A week after a vault was created without sync, sync is offered (once).
+ const [offering, setOffering] = useState(syncOfferTime.due);
+ // Sync turned on another way (Sync in the sidebar, an invite): no offer any more.
+ useEffect(() => {
+ if (syncStatus?.enabled) syncOfferTime.forget();
+ }, [syncStatus?.enabled]);
const [prompts, setPrompts] = useState([]);
// A security key waiting for a touch to sign in.
const [touch, setTouch] = useState(null);
const [changedKeys, setChangedKeys] = useState([]);
+ // A feed's snippet that changed since this device last sent it, waiting for a yes.
+ const [checking, setChecking] = useState<{ snippet: SnippetView; run: boolean; go: () => void } | null>(null);
const [vpnPrompts, setVpnPrompts] = useState([]);
const [autoLock, setAutoLock] = useState(0);
useIdleLock(autoLock, onLock);
@@ -409,6 +437,32 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
}
}, []);
+ // Feeds refresh themselves on desktops: once the first sync since unlocking has brought what
+ // other devices refreshed (at once without sync; after 20 s at the latest), then every 15
+ // minutes. The backend skips a feed refreshed on any device in the last 10.
+ const feedsRan = useRef(false);
+ const feedsSyncAt = useRef(undefined);
+ const refreshFeeds = useCallback(() => {
+ feedsRan.current = true;
+ // What it changed shows at once (the lists otherwise follow only a sync).
+ void cloudApi.autoRefresh().then((n) => n > 0 && void refresh(), () => {});
+ }, [refresh]);
+ useEffect(() => {
+ if (mobileOs) return;
+ const first = window.setTimeout(() => feedsRan.current || refreshFeeds(), 20_000);
+ const every = window.setInterval(refreshFeeds, 15 * 60_000);
+ return () => (window.clearTimeout(first), window.clearInterval(every));
+ }, [refreshFeeds]);
+ useEffect(() => {
+ if (mobileOs || feedsRan.current || !syncStatus) return;
+ const syncing = syncStatus.enabled && !syncStatus.signedOut;
+ if (feedsSyncAt.current === undefined) {
+ feedsSyncAt.current = syncStatus.lastSyncAt;
+ if (syncing) return;
+ }
+ if (!syncing || (!syncStatus.syncing && syncStatus.lastSyncAt !== feedsSyncAt.current)) refreshFeeds();
+ }, [syncStatus, refreshFeeds]);
+
// After a move, the editor in front shows the item where it went (a copy has a new id).
const moves = useMemo(
() => ({
@@ -421,9 +475,15 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
[vaults, refresh],
);
+ const [loaded, setLoaded] = useState(false);
useEffect(() => {
- void refresh();
+ void refresh().then(() => setLoaded(true));
}, [refresh]);
+ // Opened (unlocked) with hosts: home is the hosts, unless a link filled in quick connect meanwhile.
+ // The view as it is now: a page opened in the moment before this runs stays open.
+ useEffect(() => {
+ if (loaded && !linked?.connect) setView((v) => (v.kind === "quick" ? home() : v));
+ }, [loaded]);
useEffect(() => {
if (view.kind === "tab" && tabs.find((t) => t.key === view.key)?.kind === "terminal") setLastTerminal(view.key);
@@ -509,6 +569,12 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
}
openTab({ kind: "saved", hostId: h.id, password, tmux }, h.label, kind, place);
};
+ // A host's session: its latest tab (or a new one, if it closed meanwhile).
+ const showSession = (h: HostView) => {
+ const t = [...tabs].reverse().find((x) => x.target.kind === "saved" && x.target.hostId === h.id);
+ if (t) open({ kind: "tab", key: t.key });
+ else connectHost(h);
+ };
const busy = (id: string, on: boolean) =>
setPending((p) => {
@@ -563,7 +629,7 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
// The next pane of the same split, else the last tab.
const sibling = splitOf(view.key) && panes(splitOf(view.key)!.layout).find((k) => !keys.includes(k));
const next = sibling ?? rest.at(-1)?.key;
- setView(next !== undefined ? { kind: "tab", key: next } : { kind: "quick" });
+ setView(next !== undefined ? { kind: "tab", key: next } : home());
}
};
const closeTab = (key: number) => closeTabs([key]);
@@ -640,7 +706,20 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
if (view.kind !== "tab" || view.key !== key) open({ kind: "tab", key });
};
- const sendSnippet = (body: string, run: boolean) => sendTarget && sendTo(sendTarget.key, body, run);
+ /** What this device sent of a feed's snippet: a later change of it asks first. */
+ const markSent = (id: string) =>
+ void snippetsApi.sent(id).then(
+ () => setSnippets((all) => all.map((x) => (x.id === id ? { ...x, changed: false } : x))),
+ () => {},
+ );
+ /** Sends a snippet (`go`), asking first where its feed changed it since this device last did. */
+ const guardSnippet = (snippet: SnippetView | undefined, run: boolean, go: () => void) => {
+ if (snippet?.source && snippet.changed) return setChecking({ snippet, run, go });
+ go();
+ if (snippet?.source) markSent(snippet.id);
+ };
+ const sendSnippet = (body: string, run: boolean, snippet?: SnippetView) =>
+ sendTarget && guardSnippet(snippet, run, () => sendTo(sendTarget.key, body, run));
const hostOf = (t: Tab) => (t.target.kind === "saved" ? hostList.find((h) => h.id === (t.target as { hostId: string }).hostId) : undefined);
const current = tabs.find((t) => t.key === activeTab);
@@ -823,7 +902,7 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
/>
)}
{view.kind === "logs" && (
-
+
)}
{view.kind === "teams" && (
setSsoFor({ orgId, orgName })}
+ totpRequired={syncStatus?.totpRequired.map((o) => o.orgId) ?? []}
+ onTotp={() => open({ kind: "sync", totp: true })}
invite={linked?.invite}
openOrg={linked?.org}
/>
)}
{view.kind === "sync" && syncStatus && (
void syncApi.status().then(setSyncStatus).then(refresh)}
@@ -885,17 +969,21 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
vaults={vaults}
live={live}
onConnect={(h) => connectHost(h)}
+ onShowSession={showSession}
onFiles={(h) => connectHost(h, undefined, "sftp")}
onEdit={(id) => open({ kind: "host", id })}
onEditGroup={(id) => open({ kind: "group", id })}
onOpenGroup={(id) => open({ kind: "groupPage", id })}
onImport={() => open({ kind: "importHosts" })}
- onCloud={() => open({ kind: "cloud" })}
+ onCloud={(add) => open({ kind: "cloud", add })}
+ onQuickConnect={mobile ? undefined : () => open({ kind: "quick" })}
onClose={close}
/>
)}
{view.kind === "importHosts" && void refresh()} onClose={close} />}
- {view.kind === "cloud" && void refresh()} onClose={close} />}
+ {view.kind === "cloud" && (
+ void refresh()} onClose={close} />
+ )}
{(view.kind === "groups" || (view.kind === "groupPage" && !groupList.some((g) => g.id === view.id))) && (
void refresh()}
groups={groupList}
@@ -904,6 +992,7 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
keys={keyList}
vpns={vpnList}
vaults={vaults}
+ live={live}
onOpen={(id) => open({ kind: "groupPage", id })}
onEdit={(id) => open({ kind: "group", id })}
onClose={close}
@@ -922,6 +1011,7 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
live={live}
pending={pending}
onConnect={(h) => connectHost(h)}
+ onShowSession={showSession}
onFiles={(h) => connectHost(h, undefined, "sftp")}
onEditHost={(id) => open({ kind: "host", id })}
onEditGroup={(id) => open({ kind: "group", id })}
@@ -945,6 +1035,8 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
onOpenKey={(id) => open({ kind: "key", id })}
onEditIdentity={(id) => open({ kind: "identity", id })}
onEditHostCa={(id) => open({ kind: "hostCa", id })}
+ tab={view.tab ?? "keys"}
+ onTab={(tab) => setView({ kind: "keys", tab })}
onClose={close}
/>
)}
@@ -965,6 +1057,7 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
forwards={forwardList}
hosts={hostList}
vaults={vaults}
+ live={live}
pending={pending}
onToggle={toggleForward}
onOpen={(id) => open({ kind: "forward", id })}
@@ -984,6 +1077,7 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
later={mobile ? "More › Sync" : "Sync, at the bottom of the sidebar"}
onDone={(enabled) => {
setOffering(false);
+ syncOfferTime.forget();
if (enabled) void syncApi.status().then(setSyncStatus).then(refresh);
}}
/>
@@ -1009,6 +1103,19 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
)}
{prompts[0] && answer(prompts[0], a)} />}
+ {checking && (
+ {
+ setChecking(null);
+ if (!send) return;
+ checking.go();
+ markSent(checking.snippet.id);
+ }}
+ />
+ )}
{askPassword && (
open({ kind: "sync", totp: true })}
onSso={setSsoFor}
onApproval={setApprovalFor}
- onSend={sendTo}
+ onSend={(key, body, run, snippet) => guardSnippet(snippet, run, () => sendTo(key, body, run))}
onKey={(key, k) => controls.current.get(key)?.key(k)}
onCtrl={(key, on, used) => controls.current.get(key)?.ctrl(on, used)}
/>
@@ -1099,6 +1207,7 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
activeHost={currentHost?.id ?? null}
live={live}
onConnect={(h) => connectHost(h)}
+ onShowSession={showSession}
onOpenFiles={(h) => connectHost(h, undefined, "sftp")}
onEditHost={(id) => open({ kind: "host", id })}
onEditGroup={(id) => open({ kind: "group", id })}
@@ -1107,6 +1216,7 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
onOpenSync={() => open({ kind: "sync" })}
vaults={vaults}
onOpenTeams={() => open({ kind: "teams" })}
+ onTotp={() => open({ kind: "sync", totp: true })}
onSso={setSsoFor}
onApproval={setApprovalFor}
onOpenAppearance={() => open({ kind: "appearance" })}
@@ -1182,10 +1292,12 @@ function Workspace({ onLock, termSettings, onSettingsChanged: loadTermSettings,
vpns={vpnList}
onFiles={currentHost && (() => connectHost(currentHost, undefined, "sftp"))}
snippets={snippetList}
- onSend={(body, run) =>
- currentSplit?.broadcast
- ? panes(currentSplit.layout).forEach((k) => tabs.find((t) => t.key === k)?.status === "connected" && sendTo(k, body, run, false))
- : sendTo(current.key, body, run)
+ onSend={(snippet, run) =>
+ guardSnippet(snippet, run, () =>
+ currentSplit?.broadcast
+ ? panes(currentSplit.layout).forEach((k) => tabs.find((t) => t.key === k)?.status === "connected" && sendTo(k, snippet.body, run, false))
+ : sendTo(current.key, snippet.body, run),
+ )
}
onSplit={splitWith}
broadcast={currentSplit ? { on: currentSplit.broadcast, panes: panes(currentSplit.layout).length } : null}
diff --git a/apps/desktop/src/CloudPage.tsx b/apps/desktop/src/CloudPage.tsx
index 0450f48..33dc269 100644
--- a/apps/desktop/src/CloudPage.tsx
+++ b/apps/desktop/src/CloudPage.tsx
@@ -11,6 +11,7 @@ import {
type CloudRefresh,
type CloudSource,
type DeviceAuth,
+ type FeedInput,
type HostView,
type KeyView,
type SourceSettings,
@@ -31,8 +32,15 @@ function ago(seconds: number, now = Date.now() / 1000) {
return dateText(seconds * 1000);
}
-const refreshed = (r: CloudRefresh) =>
- [r.added && `${plural(r.added, "host")} added`, r.updated && `${r.updated} updated`, r.gone && `${r.gone} gone from the cloud`]
+const refreshed = (r: CloudRefresh, feed: boolean) =>
+ [
+ r.added && `${plural(r.added, "host")} added`,
+ r.updated && `${r.updated} updated`,
+ r.gone && `${r.gone} gone from the ${feed ? "feed" : "cloud"}`,
+ r.snippetsAdded && `${plural(r.snippetsAdded, "snippet")} added`,
+ r.snippetsUpdated && `${plural(r.snippetsUpdated, "snippet")} changed`,
+ r.snippetsRemoved && `${plural(r.snippetsRemoved, "snippet")} removed`,
+ ]
.filter(Boolean)
.join(", ") || "Nothing changed";
@@ -42,14 +50,17 @@ interface Note {
skipped?: string[];
withoutAddress?: string[];
couldNot?: string[];
+ /** A feed's entries left out (what `couldNot` holds for a cloud). */
+ feed?: boolean;
}
-const noteOf = (sourceId: string, r: CloudRefresh): Note => ({
+const noteOf = (sourceId: string, r: CloudRefresh, feed = false): Note => ({
sourceId,
- text: refreshed(r),
+ text: refreshed(r, feed),
skipped: r.withoutShell,
withoutAddress: r.withoutAddress,
couldNot: r.skipped,
+ feed,
});
/** A sign-in in progress: the code, the page to type it at, until it's approved. */
@@ -228,6 +239,85 @@ function SettingsForm({
);
}
+/** A feed's name, address and token, for adding one or changing it. */
+function FeedForm({
+ source,
+ keys,
+ vaultId,
+ busy,
+ submit,
+ onSubmit,
+ onCancel,
+}: {
+ source?: CloudSource;
+ keys: KeyView[];
+ vaultId: string;
+ busy: boolean;
+ submit: string;
+ onSubmit: (input: FeedInput, keyId: string | null) => void;
+ onCancel: () => void;
+}) {
+ const [label, setLabel] = useState(source?.label ?? "");
+ const [url, setUrl] = useState(source?.url ?? "");
+ const [token, setToken] = useState("");
+ const [keyId, setKeyId] = useState("");
+ const vaultKeys = keys.filter((k) => k.vaultId === vaultId);
+ const valid = /^https:\/\/\S+$|^http:\/\/(localhost|127\.0\.0\.1|\[::1\])([:/]\S*)?$/.test(url.trim()) && (!!source || token.trim() !== "");
+ return (
+
+ );
+}
+
/** Clouds: their machines and environments as hosts, kept in step. Vallic Cloud signs in on each
* device (a code typed in the browser); AWS, Google Cloud and Azure list with their CLI on this
* device. The hosts sync like any others. */
@@ -235,12 +325,15 @@ export function CloudPage({
vaults,
keys,
hosts,
+ adding: addingFirst,
onChanged,
onClose,
}: {
vaults: VaultEntry[];
keys: KeyView[];
hosts: HostView[];
+ /** Opened to add this (Add from… → Feed URL…). */
+ adding?: CloudProvider;
onChanged: () => void;
onClose: () => void;
}) {
@@ -251,7 +344,7 @@ export function CloudPage({
const [note, setNote] = useState(null);
const [busy, setBusy] = useState(null);
const [removing, setRemoving] = useState(null);
- const [adding, setAdding] = useState(null);
+ const [adding, setAdding] = useState(addingFirst ?? null);
const [editing, setEditing] = useState(null);
// The poll of the sign-in on screen (a new sign-in or Cancel ends it).
const polling = useRef(0);
@@ -307,6 +400,16 @@ export function CloudPage({
);
};
+ const addFeed = (input: FeedInput, keyId: string | null) => {
+ setBusy("adding");
+ setError("");
+ setNote(null);
+ cloud.addFeed(input, { vaultId, keyId }).then(
+ (r) => (setBusy(null), setAdding(null), setNote(noteOf(r.source.id, r.refresh, true)), load(), onChanged()),
+ (e) => (setBusy(null), setError(errorText(e)), load()),
+ );
+ };
+
const add = (provider: CloudProvider, settings: SourceSettings, keyId: string | null) => {
setBusy("adding");
setError("");
@@ -320,7 +423,7 @@ export function CloudPage({
return (
@@ -348,10 +451,13 @@ export function CloudPage({
const vaultKeys = keys.filter((k) => k.vaultId === s.vaultId);
const key = vaultKeys.find((k) => k.id === s.keyId);
const vallic = s.provider === "vallic";
+ const feed = s.provider === "feed";
const provider = PROVIDERS[s.provider]?.name ?? s.provider;
const how = vallic
? null
- : [
+ : feed
+ ? `${s.url ?? ""} · token ${s.tokenEnd ?? "…"}`
+ : [
s.profile && `${s.provider === "aws" ? "profile" : "context"} ${s.profile}`,
s.scopes.length
? s.scopes.join(", ")
@@ -384,12 +490,13 @@ export function CloudPage({
{s.account && `${s.account} · `}
{plural(s.hosts, "host")}
{s.gone > 0 && ` (${s.gone} gone)`}
+ {s.snippets > 0 && ` · ${plural(s.snippets, "snippet")}`}
{s.refreshedAt > 0 && ` · refreshed ${ago(s.refreshedAt)}`}
- {how &&
{how}
}
+ {how &&
{how}
}
{s.signedIn ? (
- act(s.id, cloud.refresh(s.id), (r) => setNote(noteOf(s.id, r as CloudRefresh)))}>
+ act(s.id, cloud.refresh(s.id), (r) => setNote(noteOf(s.id, r as CloudRefresh, feed)))}>
{busy === s.id ? "Refreshing…" : "Refresh"}
) : (
@@ -407,10 +514,31 @@ export function CloudPage({
Without {s.reach === "private" ? "a private" : "a public"} address: {note.withoutAddress.join(", ")}.
)}
- {note.couldNot && note.couldNot.length > 0 && Couldn't list: {note.couldNot.join("; ")}
}
+ {note.couldNot && note.couldNot.length > 0 && (
+
+ {note.feed ? "Skipped" : "Couldn't list"}: {note.couldNot.join("; ")}
+
+ )}
>
)}
- {editing === s.id && !vallic ? (
+ {s.failed && note?.sourceId !== s.id && busy !== s.id && The last refresh failed: {s.failed}
}
+ {editing === s.id && feed ? (
+ setEditing(null)}
+ onSubmit={(input) =>
+ act(
+ s.id,
+ cloud.feedSettings(s.id, input).then(() => cloud.refresh(s.id)),
+ (r) => (setEditing(null), setNote(noteOf(s.id, r as CloudRefresh, true))),
+ )
+ }
+ />
+ ) : editing === s.id && !vallic ? (
(setRemoving(null), act(s.id, cloud.remove(s.id, true)))}>
- Remove it and its hosts
+ {s.snippets > 0 ? "Remove it, its hosts and snippets" : "Remove it and its hosts"}
(setRemoving(null), act(s.id, cloud.remove(s.id, false)))}>
- Remove it, keep the hosts
+ {s.snippets > 0 ? "Remove it, keep the hosts and snippets" : "Remove it, keep the hosts"}
setRemoving(null)}>
Cancel
@@ -490,7 +618,27 @@ export function CloudPage({
);
})}
- {adding && (
+ {adding === "feed" && (
+
+ Add a feed
+
+ Hosts and snippets from a JSON document at an address, read with a token: from Terraform output, a CMDB or an internal endpoint. Its hosts and
+ snippets stay in step with it; your renames, keys, tags and notes stay yours.
+
+
+ setAdding(null)}
+ onSubmit={addFeed}
+ />
+
+ )}
+
+ {adding && adding !== "feed" && (
Add {PROVIDERS[adding].name}
@@ -522,6 +670,9 @@ export function CloudPage({
Add {PROVIDERS[p].name}
))}
+ (setError(""), setNote(null), setAdding("feed"))}>
+ Add a feed
+
)}
diff --git a/apps/desktop/src/HostKeyDialog.tsx b/apps/desktop/src/HostKeyDialog.tsx
index 25f93b4..c6ecbff 100644
--- a/apps/desktop/src/HostKeyDialog.tsx
+++ b/apps/desktop/src/HostKeyDialog.tsx
@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { keyType, type ChangedHostKey, type HostKeyPrompt } from "./api";
+import { copyText } from "./clipboard";
const where = (host: string, port: number) => (port === 22 ? host : `[${host}]:${port}`);
@@ -51,6 +52,7 @@ export function HostKeyDialog({ prompt, onAnswer }: { prompt: HostKeyPrompt; onA
export function HostKeyChangedDialog({ changed, label, onClose }: { changed: ChangedHostKey; label?: string; onClose: () => void }) {
const ref = useRef(null);
const [copied, setCopied] = useState(false);
+ const [copyFailed, setCopyFailed] = useState(false);
useEffect(() => {
ref.current?.showModal();
@@ -66,11 +68,7 @@ export function HostKeyChangedDialog({ changed, label, onClose }: { changed: Cha
.filter(Boolean)
.join("\n");
- const copy = () =>
- void navigator.clipboard.writeText(details).then(
- () => setCopied(true),
- () => {},
- );
+ const copy = () => void copyText(details).then((ok) => (ok ? setCopied(true) : setCopyFailed(true)));
return (
- {copied ? "Copied" : "Copy details"}
+ {copied ? "Copied" : copyFailed ? "Couldn't copy" : "Copy details"}
Close
diff --git a/apps/desktop/src/KeyPanel.tsx b/apps/desktop/src/KeyPanel.tsx
index f5165da..634a751 100644
--- a/apps/desktop/src/KeyPanel.tsx
+++ b/apps/desktop/src/KeyPanel.tsx
@@ -2,6 +2,7 @@ import { useState, type FormEvent } from "react";
import { errorText, keys as keysApi, type CertInfo, type KeySource, type KeyView, type VaultEntry } from "./api";
import { FilePick, type PickedFile } from "./FilePick";
import { isReadOnly, personalVault, VaultField } from "./VaultField";
+import { copyText } from "./clipboard";
/** A certificate's validity now, as a line and a tone. */
export function certificateValidity(c: CertInfo, now = Date.now() / 1000): { text: string; tone: "ok" | "warn" | "error" } {
@@ -222,6 +223,7 @@ export function KeyDetail({
}) {
const readOnly = isReadOnly(vaults, k.vaultId);
const [copied, setCopied] = useState(false);
+ const [copyFailed, setCopyFailed] = useState(false);
const [error, setError] = useState("");
const [confirmDelete, setConfirmDelete] = useState(false);
@@ -274,9 +276,9 @@ export function KeyDetail({
void navigator.clipboard?.writeText(k.publicKey).then(() => setCopied(true))}
+ onClick={() => void copyText(k.publicKey).then((ok) => (ok ? setCopied(true) : setCopyFailed(true)))}
>
- {copied ? "Copied" : "Copy public key"}
+ {copied ? "Copied" : copyFailed ? "Couldn't copy" : "Copy public key"}
diff --git a/apps/desktop/src/Licenses.tsx b/apps/desktop/src/Licenses.tsx
index 040cb21..52bc214 100644
--- a/apps/desktop/src/Licenses.tsx
+++ b/apps/desktop/src/Licenses.tsx
@@ -5,7 +5,7 @@ interface Component {
version?: string;
/** SPDX expression, as the package declares it. */
license: string;
- kind: "crate" | "npm" | "font" | "android";
+ kind: "crate" | "npm" | "font" | "data" | "android";
url?: string;
/** Indexes into `texts`. */
texts: number[];
@@ -21,7 +21,7 @@ interface Notices {
// missing under `npm run dev`. Loaded when this opens: it's most of a megabyte.
const generated = import.meta.glob("./generated/licenses.json", { import: "default" });
-const KINDS: Record = { crate: "Rust crate", npm: "npm package", font: "font", android: "Android library" };
+const KINDS: Record = { crate: "Rust crate", npm: "npm package", font: "font", data: "word list", android: "Android library" };
/** About → Open-source licenses: the third-party components in this build, with their notices. */
export function Licenses({ onBack }: { onBack: () => void }) {
diff --git a/apps/desktop/src/ListParts.tsx b/apps/desktop/src/ListParts.tsx
new file mode 100644
index 0000000..7ed6633
--- /dev/null
+++ b/apps/desktop/src/ListParts.tsx
@@ -0,0 +1,226 @@
+// The list pattern the pages share (Lists.tsx, TeamsPanel.tsx): a table with a row per item, its
+// name first (its main action), the columns people scan, then the row's other actions, shown on
+// hover or keyboard focus; a toolbar with search, filters and Select; states as squares and chips.
+
+import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
+import { Icon, type IconName } from "./Icon";
+import type { pickOf, Selection } from "./Selection";
+import type { LiveState } from "./Sidebar";
+
+/** What a row needs while its list is selecting (see Selection.tsx). */
+export type Pick = ReturnType;
+
+/** A list as a table. `cols`: the grid's columns, the actions' column last; `head`: their titles. */
+export function Table({ cols, label, head, children }: { cols: string; label: string; head: string[]; children: ReactNode }) {
+ return (
+
+
+ {head.map((h, i) => (
+
+ {h}
+
+ ))}
+
+ Actions
+
+
+ {children}
+
+ );
+}
+
+/** One row: its cells, then its actions. Picking: the whole row toggles it, and no actions. */
+export function TRow({ index, pick, className, children, actions }: { index: number; pick?: Pick; className?: string; children: ReactNode; actions?: ReactNode }) {
+ const classes = ["lrow", index % 2 === 1 && "band", pick && "picking", pick?.on && "picked", className].filter(Boolean).join(" ");
+ return (
+
+ {children}
+
+ {!pick && actions}
+
+
+ );
+}
+
+/** A row's first cell: its name (a button for the row's main action) with what goes beside it,
+ * and a line under it. Picking: a checkbox in front, and the name is only text. */
+export function NameCell({
+ pick,
+ onOpen,
+ title,
+ lead,
+ name,
+ beside,
+ sub,
+ subTone,
+}: {
+ pick?: Pick;
+ onOpen?: () => void;
+ /** What the name's button does ("Connect to web-1"). */
+ title?: string;
+ /** Before the name: a status square or an icon. */
+ lead?: ReactNode;
+ name: string;
+ beside?: ReactNode;
+ /** The line under it: text only, so it shrinks to an ellipsis (the whole of it on hover). */
+ sub?: string;
+ /** That line is machine text (an address), or a problem. */
+ subTone?: "mono" | "error";
+}) {
+ const inner = (
+ <>
+ {lead}
+
+
+ {name}
+ {beside}
+
+ {sub && (
+
+ {sub}
+
+ )}
+
+ >
+ );
+ return (
+
+ {pick && (
+ e.stopPropagation()} aria-label={`Select ${pick.label}`} />
+ )}
+ {pick || !onOpen ? (
+ {inner}
+ ) : (
+
+ {inner}
+
+ )}
+
+ );
+}
+
+export const Cell = ({ children, className }: { children?: ReactNode; className?: string }) => (
+
+ {children}
+
+);
+
+/** A connection's state as a square, from the mark's cursor: shape and colour. */
+export function StatusSq({ state }: { state: LiveState | undefined }) {
+ return ;
+}
+
+/** A state in a word, with its square. */
+export function Chip({ state, children }: { state: "connected" | "idle" | "attention" | "failed"; children: ReactNode }) {
+ return (
+
+
+ {children}
+
+ );
+}
+
+/** A row action as an icon. */
+export function IconAction({ icon, label, onClick }: { icon: IconName; label: string; onClick: () => void }) {
+ return (
+
+
+
+ );
+}
+
+/** A row action in words (Connect, Run). */
+export function TextAction({ children, onClick, warn }: { children: ReactNode; onClick: () => void; warn?: boolean }) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** A list's toolbar: the search, filters, and Select (which the bar replaces while selecting). */
+export function Toolbar({
+ query,
+ setQuery,
+ placeholder,
+ label,
+ filters,
+ sel,
+ any,
+}: {
+ query: string;
+ setQuery: (q: string) => void;
+ placeholder: string;
+ label: string;
+ filters?: ReactNode;
+ sel: Selection;
+ any: boolean;
+}) {
+ return (
+
+
+
+
+
+
+ setQuery(e.target.value)} spellCheck={false} />
+
+ {filters}
+
+ {any && !sel.on && (
+
+ Select
+
+ )}
+
+ );
+}
+
+/** Filter buttons: one shown at a time ("all", or one of the others). */
+export function Filters({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: { key: string; label: string; count?: number }[] }) {
+ return (
+
+ {options.map((o) => (
+ onChange(value === o.key ? "all" : o.key)}>
+ {o.label}
+ {o.count !== undefined && {o.count} }
+
+ ))}
+
+ );
+}
+
+/** "Add from…": the other ways to add, in a menu. */
+export function AddFrom({ items }: { items: { label: string; sub: string; onClick: () => void }[] }) {
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+ useEffect(() => {
+ if (!open) return;
+ const away = (e: MouseEvent) => {
+ if (!ref.current?.contains(e.target as Node)) setOpen(false);
+ };
+ const esc = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
+ document.addEventListener("mousedown", away);
+ document.addEventListener("keydown", esc);
+ return () => (document.removeEventListener("mousedown", away), document.removeEventListener("keydown", esc));
+ }, [open]);
+ return (
+
+
setOpen(!open)}>
+ Add from…
+
+
+ {open && (
+
+ {items.map((it) => (
+ (setOpen(false), it.onClick())}>
+ {it.label}
+ {it.sub}
+
+ ))}
+
+ )}
+
+ );
+}
+
diff --git a/apps/desktop/src/Lists.tsx b/apps/desktop/src/Lists.tsx
index 5199ce9..b769ed3 100644
--- a/apps/desktop/src/Lists.tsx
+++ b/apps/desktop/src/Lists.tsx
@@ -1,6 +1,10 @@
// Pages that list one kind of item each: hosts (by group), groups, keys and identities, snippets,
// tunnels and VPNs, and a group's page with everything in it. The sidebar keeps the host list for
// connecting; everything is managed here.
+//
+// Every list is a table: a row per item, its name first (its main action: connect a host, open
+// anything else), then what people scan for, then the row's other actions, shown on hover or
+// keyboard focus. Every other row carries the greenbar band. One primary button per page.
import { useEffect, useState, type ReactNode } from "react";
import {
@@ -24,7 +28,8 @@ import {
type VaultEntry,
type VpnView,
} from "./api";
-import { Icon } from "./Icon";
+import { Icon, type IconName } from "./Icon";
+import { AddFrom, Cell, Chip, Filters, IconAction, NameCell, StatusSq, Table, TextAction, Toolbar, TRow } from "./ListParts";
import { MoveButton } from "./Move";
import { Page } from "./Page";
import type { LiveState } from "./Sidebar";
@@ -34,16 +39,6 @@ import { eachOf, outcome, pickOf, SelectBar, useSelection, type Selection } from
const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
-/** "Select", for a list with something in it that isn't selecting yet. */
-function SelectButton({ sel, any }: { sel: Selection; any: boolean }) {
- if (!any || sel.on) return null;
- return (
-
- Select
-
- );
-}
-
/** The vault's name, for items in shared vaults. */
function VaultTag({ vaults, id }: { vaults: VaultEntry[]; id: string | undefined }) {
const v = vaults.find((x) => x.id === id);
@@ -66,51 +61,6 @@ function Empty({ text, action, onAction }: { text: string; action: string; onAct
);
}
-/** What a row needs while its list is selecting (see Selection.tsx). */
-type Pick = ReturnType;
-
-/** One row: an avatar, a title line, detail lines, then buttons. Picking (`pick`): a checkbox
- * instead of the buttons, and the whole row toggles it. */
-function Row({
- avatar,
- title,
- lines,
- children,
- on,
- pick,
-}: {
- avatar: ReactNode;
- title: ReactNode;
- lines: ReactNode[];
- children?: ReactNode;
- on?: boolean;
- pick?: Pick;
-}) {
- const classes = [on && "on", pick && "picking", pick?.on && "picked"].filter(Boolean).join(" ");
- return (
-
- {pick && (
- e.stopPropagation()}
- aria-label={`Select ${pick.label}`}
- />
- )}
- {avatar}
-
- {title}
- {lines.filter(Boolean).map((l, i) => (
- {l}
- ))}
-
- {children && !pick && {children} }
-
- );
-}
-
// ------------------------------------------------------------------ hosts
/** What hosts are described with. */
@@ -131,23 +81,66 @@ const nameOf = (id: string | null, list: { id?: string; label?: string; name?: s
return it ? (it.label ?? it.name ?? "?") : "?";
};
-/** How a host connects, in a line. */
-const hostHow = (h: HostView, t: Things) => {
- const parts: string[] = [];
- if (h.jumpId) parts.push(`via ${nameOf(h.jumpId, t.hosts)}`);
- else if (h.source?.via) parts.push(`through ${REACH_NAMES[h.source.via]}`);
- else if (h.proxyCommand) parts.push(h.proxyAllowed ? "through a proxy command" : "through a proxy command not allowed on this device");
- else if (h.vpnId) parts.push(`VPN ${nameOf(h.vpnId, t.vpns)}`);
+/** How a host is reached: its jump host, cloud route or proxy command, and its VPN. */
+function HostRoute({ h, t }: { h: HostView; t: Things }) {
+ const vpn = h.vpnId ? t.vpns.find((v) => v.id === h.vpnId) : undefined;
+ const route = h.jumpId ? (
+ <>
+ via {nameOf(h.jumpId, t.hosts)}
+ >
+ ) : h.source?.via ? (
+ through {REACH_NAMES[h.source.via]}
+ ) : h.proxyCommand ? (
+ {h.proxyAllowed ? "proxy command" : "proxy command not allowed here"}
+ ) : (
+ direct
+ );
+ return (
+ <>
+ {route}
+ {vpn && (
+
+ {vpn.label}
+
+ )}
+ >
+ );
+}
+
+/** How a host signs in, and what it does beyond that. */
+function hostSignIn(h: HostView, t: Things): { how: ReactNode; more: string } {
const identity = identityOf(h, t.groups);
- if (identity) parts.push(`identity ${nameOf(identity, t.identities)}${h.identityId ? "" : " (the group's)"}`);
- else if (h.auth.kind === "key") parts.push(`key ${nameOf(h.auth.keyId, t.keys)}`);
- else parts.push(h.auth.kind === "agent" ? agentDescription(h.auth) : h.auth.saved ? "saved password" : "password each time");
- if (h.keepSession) parts.push("keeps its session");
- if (h.recordSessions) parts.push("recorded");
- return parts.join(" · ");
+ const how = identity ? (
+ <>
+ identity {nameOf(identity, t.identities)}
+ {h.identityId ? "" : " (the group's)"}
+ >
+ ) : h.auth.kind === "key" ? (
+ <>
+ key {nameOf(h.auth.keyId, t.keys)}
+ >
+ ) : h.auth.kind === "agent" ? (
+ agentDescription(h.auth)
+ ) : h.auth.saved ? (
+ "saved password"
+ ) : (
+ "password each time"
+ );
+ const more = [h.keepSession && "keeps its session", h.recordSessions && "recorded"].filter(Boolean).join(" · ");
+ return { how, more };
+}
+
+/** What a group gives its hosts, in parts. */
+const groupParts = (g: GroupView, t: Things) => {
+ const parts: ReactNode[] = [];
+ if (g.identityId) parts.push(identity {nameOf(g.identityId, t.identities)} );
+ if (g.jumpId) parts.push(via {nameOf(g.jumpId, t.hosts)} );
+ if (g.vpnId) parts.push(VPN {nameOf(g.vpnId, t.vpns)} );
+ if (g.keepSession) parts.push(keeps sessions );
+ return parts;
};
-/** What a group gives its hosts, in a line. */
+/** The same in a line, for text. */
const groupHow = (g: GroupView, t: Things) => {
const parts: string[] = [];
if (g.identityId) parts.push(`identity ${nameOf(g.identityId, t.identities)}`);
@@ -157,12 +150,17 @@ const groupHow = (g: GroupView, t: Things) => {
return parts.join(" · ");
};
+const HOST_COLS = "minmax(170px, 1.6fr) minmax(110px, 1fr) minmax(110px, 1fr) minmax(60px, 0.6fr) 116px";
+const HOST_HEAD = ["Host", "Route", "Signs in with", "Tags"];
+
interface HostRowsProps {
list: HostView[];
things: Things;
vaults: VaultEntry[];
live: Map;
onConnect: (h: HostView) => void;
+ /** A host with a session: its tab. */
+ onShowSession?: (h: HostView) => void;
onFiles: (h: HostView) => void;
onEdit: (id?: string) => void;
sel?: Selection;
@@ -170,64 +168,67 @@ interface HostRowsProps {
function HostRows({ list, things, ...p }: HostRowsProps) {
return (
-
- {list.map((h) => {
+ <>
+ {list.map((h, i) => {
const state = p.live.get(h.id);
+ const { how, more } = hostSignIn(h, things);
+ const toSession = state && p.onShowSession;
return (
-
- {(h.label[0] ?? "?").toUpperCase()}
- {state && }
- >
- }
- title={
+ actions={
<>
- {h.label}
-
- {h.source?.gone && (
-
- gone from {PROVIDERS[h.source.provider || "vallic"]?.name ?? "its cloud"}
-
- )}
- {h.source?.state && !h.source.gone && (
-
- {h.source.state.replace(/^VM /, "").toLowerCase()}
-
- )}
- {h.tags.map((t) => (
-
- {t}
-
- ))}
+ p.onConnect(h)}>{state ? "New tab" : "Connect"}
+ {!h.source?.noSftp && p.onFiles(h)} />}
+ p.onEdit(h.id)} />
>
}
- lines={[
-
- {h.user}@{h.hostname}
- {h.port !== 22 ? `:${h.port}` : ""}
- ,
- hostHow(h, things),
- ]}
>
- p.onConnect(h)}>
- Connect
-
- {!h.source?.noSftp && (
- p.onFiles(h)}>
- Files
-
- )}
- p.onEdit(h.id)} aria-label={`Edit ${h.label}`}>
- Edit
-
-
+ (toSession ? p.onShowSession!(h) : p.onConnect(h))}
+ title={toSession ? `Go to ${h.label}'s session` : `Connect to ${h.user}@${h.hostname}:${h.port}`}
+ lead={ }
+ name={h.label}
+ beside={
+ <>
+
+ {h.source?.gone && (
+
+ gone from {h.source.provider === "feed" ? "its feed" : (PROVIDERS[h.source.provider || "vallic"]?.name ?? "its cloud")}
+
+ )}
+ {h.source?.state && !h.source.gone && (
+
+ {h.source.state.replace(/^VM /, "").toLowerCase()}
+
+ )}
+ >
+ }
+ sub={`${h.user}@${h.hostname}:${h.port}`}
+ subTone="mono"
+ />
+
+
+ |
+
+ {how}
+ {more && {more} }
+ |
+
+ {h.tags.slice(0, 2).map((t) => (
+
+ {t}
+
+ ))}
+ {h.tags.length > 2 && +{h.tags.length - 2} }
+ |
+
);
})}
-
+ >
);
}
@@ -240,13 +241,17 @@ interface HostsProps {
vaults: VaultEntry[];
live: Map;
onConnect: (h: HostView) => void;
+ onShowSession: (h: HostView) => void;
onFiles: (h: HostView) => void;
onEdit: (id?: string) => void;
onEditGroup: (id?: string) => void;
onOpenGroup: (id: string) => void;
/** Hosts from OpenSSH's configuration (desktop). */
onImport: () => void;
- onCloud: () => void;
+ /** Clouds, or straight to adding a feed. */
+ onCloud: (add?: "feed") => void;
+ /** Quick connect (desktop, where this page is home once there are hosts). */
+ onQuickConnect?: () => void;
/** After a change made here (several deleted, moved to a group). */
onChanged: () => void;
onClose: () => void;
@@ -256,33 +261,45 @@ const matches = (h: HostView, q: string) => [h.label, h.hostname, h.user, ...h.t
export function HostsPage(p: HostsProps) {
const [query, setQuery] = useState("");
+ const [filter, setFilter] = useState("all");
const sel = useSelection();
const q = query.trim().toLowerCase();
- const shown = q ? p.hosts.filter((h) => matches(h, q)) : p.hosts;
+ const connected = p.hosts.filter((h) => p.live.has(h.id)).length;
+ // The tags most hosts carry, as filters.
+ const tagCounts = new Map();
+ p.hosts.forEach((h) => h.tags.forEach((t) => tagCounts.set(t, (tagCounts.get(t) ?? 0) + 1)));
+ const tags = [...tagCounts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 5);
+ const shown = p.hosts.filter(
+ (h) => (!q || matches(h, q)) && (filter === "all" || (filter === "connected" ? p.live.has(h.id) : h.tags.includes(filter.slice(4)))),
+ );
+ const narrowed = !!q || filter !== "all";
const ungrouped = shown.filter((h) => !h.groupId || !p.groups.some((g) => g.id === h.groupId));
- const rows = { things: p, vaults: p.vaults, live: p.live, onConnect: p.onConnect, onFiles: p.onFiles, onEdit: p.onEdit, sel };
+ const rows = { things: p, vaults: p.vaults, live: p.live, onConnect: p.onConnect, onShowSession: p.onShowSession, onFiles: p.onFiles, onEdit: p.onEdit, sel };
return (
{!mobileOs && (
-
- Import…
-
+ p.onCloud() },
+ { label: "Feed URL…", sub: "Hosts and snippets your team publishes at an address", onClick: () => p.onCloud("feed") },
+ ]}
+ />
)}
- {!mobileOs && (
-
- Clouds…
+ {p.onQuickConnect && (
+
+ Quick connect
)}
- 0} />
p.onEditGroup()}>
New group
@@ -292,90 +309,111 @@ export function HostsPage(p: HostsProps) {
>
}
>
- ({ id: h.id, vaultId: h.vaultId }))}
- noun={["host", "hosts"]}
- onDelete={bulk.remove}
- onChanged={p.onChanged}
- more={(ids, vaultId, report) =>
- vaultId && (
- {
- const group = e.target.value;
- bulk.setGroup(ids, group === "none" ? null : group).then(
- (r) => (report(outcome(r, "Moved", ["host", "hosts"])), p.onChanged()),
- (err) => report({ ok: false, text: errorText(err) }),
- );
- }}
- >
-
- Move to group…
-
- {p.groups
- .filter((g) => g.vaultId === vaultId)
- .map((g) => (
-
- {g.name}
-
- ))}
- No group
-
- )
- }
- />
{p.hosts.length === 0 ? (
p.onEdit()} />
) : (
<>
-
-
-
-
-
- setQuery(e.target.value)} spellCheck={false} />
-
- {shown.length === 0 && No hosts match “{query}”.
}
- {ungrouped.length > 0 && (
-
- {p.groups.length > 0 && Not in a group }
-
-
+ 0}
+ filters={
+ (connected > 0 || tags.length > 0) && (
+ 0 ? [{ key: "connected", label: "Connected", count: connected }] : []),
+ ...tags.map(([t, n]) => ({ key: `tag:${t}`, label: t, count: n })),
+ ]}
+ />
+ )
+ }
+ />
+ ({ id: h.id, vaultId: h.vaultId }))}
+ noun={["host", "hosts"]}
+ onDelete={bulk.remove}
+ onChanged={p.onChanged}
+ more={(ids, vaultId, report) =>
+ vaultId && (
+ {
+ const group = e.target.value;
+ bulk.setGroup(ids, group === "none" ? null : group).then(
+ (r) => (report(outcome(r, "Moved", ["host", "hosts"])), p.onChanged()),
+ (err) => report({ ok: false, text: errorText(err) }),
+ );
+ }}
+ >
+
+ Move to group…
+
+ {p.groups
+ .filter((g) => g.vaultId === vaultId)
+ .map((g) => (
+
+ {g.name}
+
+ ))}
+ No group
+
+ )
+ }
+ />
+ {shown.length === 0 ? (
+ No hosts match{q ? ` “${query}”` : " this filter"}.
+ ) : (
+
+ {ungrouped.length > 0 && (
+
+ {p.groups.length > 0 && (
+
+ Not in a group
+ {ungrouped.length}
+
+ )}
+
+
+ )}
+ {p.groups.map((g) => {
+ const members = shown.filter((h) => h.groupId === g.id);
+ if (narrowed && members.length === 0) return null;
+ const parts = groupParts(g, p);
+ return (
+
+
+ p.onOpenGroup(g.id!)} title={`Open the group ${g.name}`}>
+
+ {g.name}
+
+ {members.length}
+
+ {parts.length > 0 && {parts} }
+
+ p.onEditGroup(g.id)} aria-label={`Edit group ${g.name}`}>
+ Edit group
+
+
+ {members.length ? (
+
+ ) : (
+ No hosts in it yet. Pick the group in a host's editor.
+ )}
+
+ );
+ })}
+
)}
>
)}
- {p.groups.map((g) => {
- const members = shown.filter((h) => h.groupId === g.id);
- if (q && members.length === 0) return null;
- const summary = groupHow(g, p);
- return (
-
-
-
-
- p.onOpenGroup(g.id!)}>
- {g.name}
-
-
- {members.length}
-
-
- p.onEditGroup(g.id)} aria-label={`Edit group ${g.name}`}>
- Edit group
-
-
- {summary && Hosts here use {summary}.
}
- {members.length ? (
-
- ) : (
- No hosts in it yet. Pick the group in a host's editor.
- )}
-
- );
- })}
);
}
@@ -384,6 +422,7 @@ export function HostsPage(p: HostsProps) {
interface GroupsProps extends Things {
vaults: VaultEntry[];
+ live: Map;
onOpen: (id: string) => void;
onEdit: (id?: string) => void;
onChanged: () => void;
@@ -392,65 +431,59 @@ interface GroupsProps extends Things {
export function GroupsPage(p: GroupsProps) {
const sel = useSelection();
+ const [query, setQuery] = useState("");
+ const q = query.trim().toLowerCase();
+ const shown = q ? p.groups.filter((g) => g.name.toLowerCase().includes(q)) : p.groups;
return (
- 0} />
- p.onEdit()}>
- New group
-
- >
+ p.onEdit()}>
+ New group
+
}
>
- ({ id: g.id!, vaultId: g.vaultId! }))}
- noun={["group", "groups"]}
- onDelete={bulk.remove}
- onChanged={p.onChanged}
- />
{p.groups.length === 0 ? (
p.onEdit()} />
) : (
-
- {p.groups.map((g) => {
- const count = p.hosts.filter((h) => h.groupId === g.id).length;
- const summary = groupHow(g, p);
- return (
-
}
- title={
- <>
-
- {sel.on ? (
- g.name
- ) : (
- p.onOpen(g.id!)}>
- {g.name}
-
- )}
-
-
- >
- }
- lines={[plural(count, "host"), summary && `Its hosts use ${summary}`, sel.on && count > 0 && "Deleting it keeps its hosts"]}
- >
- p.onOpen(g.id!)} aria-label={`Open ${g.name}`}>
- Open
-
- p.onEdit(g.id)} aria-label={`Edit group ${g.name}`}>
- Edit
-
-
- );
- })}
-
+ <>
+
+ ({ id: g.id!, vaultId: g.vaultId! }))} noun={["group", "groups"]} onDelete={bulk.remove} onChanged={p.onChanged} />
+
+ {shown.map((g, i) => {
+ const members = p.hosts.filter((h) => h.groupId === g.id);
+ const live = members.filter((h) => p.live.has(h.id)).length;
+ const parts = groupParts(g, p);
+ return (
+ p.onEdit(g.id)} />}>
+ p.onOpen(g.id!)}
+ title={`Open ${g.name}`}
+ lead={ }
+ name={g.name}
+ beside={ }
+ sub={sel.on && members.length > 0 ? "Deleting it keeps its hosts" : undefined}
+ />
+ {members.length} |
+ {parts.length ? {parts} : Nothing: each host its own } |
+
+ {live > 0 ? (
+ <>
+
+ {live}
+ >
+ ) : (
+ None
+ )}
+ |
+
+ );
+ })}
+
+ >
)}
);
@@ -463,6 +496,7 @@ interface GroupProps extends Things {
live: Map;
pending: Set;
onConnect: (h: HostView) => void;
+ onShowSession: (h: HostView) => void;
onFiles: (h: HostView) => void;
onEditHost: (id?: string) => void;
onEditGroup: (id: string) => void;
@@ -475,8 +509,10 @@ interface GroupProps extends Things {
onClose: () => void;
}
-/** One group and everything in it: its hosts, the tunnels through them, their files, and what they
- * use to connect. */
+const sizeText = (n: number) => (n < 1024 ? `${n} B` : n < 1048576 ? `${Math.round(n / 1024)} KB` : `${(n / 1048576).toFixed(1)} MB`);
+
+/** One group and everything in it: what its hosts use to connect, its hosts, the tunnels through
+ * them and their files. */
export function GroupPage(p: GroupProps) {
const g = p.group;
const members = p.hosts.filter((h) => h.groupId === g.id);
@@ -505,53 +541,30 @@ export function GroupPage(p: GroupProps) {
const jumps = p.hosts.filter((j) => !memberIds.has(j.id) && members.some((h) => h.jumpId === j.id));
const summary = groupHow(g, p);
const hostLabel = (id: string | null) => p.hosts.find((h) => h.id === id)?.label ?? "";
-
- const uses = (
-
-
What its hosts use
- {identities.length + keys.length + vpns.length + jumps.length === 0 ? (
-
Nothing from the vault: they sign in with a password or the SSH agent, directly.
- ) : (
-
- {identities.map((i) => (
-
} title={{i.label} } lines={[`Identity · ${i.user}`]}>
- p.onOpenIdentity(i.id)} aria-label={`Open ${i.label}`}>
- Open
-
-
- ))}
- {keys.map((k) => (
-
} title={{k.label} } lines={[`Key · ${k.algorithm.replace(/^ssh-/, "")}`]}>
- p.onOpenKey(k.id)} aria-label={`Open ${k.label}`}>
- Open
-
-
- ))}
- {jumps.map((j) => (
-
} title={{j.label} } lines={["Jump host"]}>
- p.onEditHost(j.id)} aria-label={`Edit ${j.label}`}>
- Edit
-
-
- ))}
- {vpns.map((v) => (
-
} title={{v.label} } lines={[`VPN · ${vpnKindName(v.kind)}`]}>
- p.onOpenVpn(v.id)} aria-label={`Open ${v.label}`}>
- Open
-
-
- ))}
-
- )}
-
- );
+ const uses: { kind: string; icon: IconName; label: string; sub: ReactNode; open: () => void }[] = [
+ ...identities.map((i) => ({ kind: "Identity", icon: "identity" as const, label: i.label, sub: {i.user} , open: () => p.onOpenIdentity(i.id) })),
+ ...keys.map((k) => ({ kind: "Key", icon: "key" as const, label: k.label, sub: k.algorithm.replace(/^ssh-/, ""), open: () => p.onOpenKey(k.id) })),
+ ...jumps.map((j) => ({ kind: "Jump host", icon: "host" as const, label: j.label, sub: {j.hostname} , open: () => p.onEditHost(j.id) })),
+ ...vpns.map((v) => ({
+ kind: "VPN",
+ icon: "vpn" as const,
+ label: v.label,
+ sub: (
+ <>
+ {vpnKindName(v.kind)}
+ {v.active && up }
+ >
+ ),
+ open: () => p.onOpenVpn(v.id),
+ })),
+ ];
return (
}
- lead={summary ? `Its hosts use ${summary}, unless they set their own.` : "Its hosts each sign in and connect their own way."}
+ lead={`${plural(members.length, "host")}. ${summary ? "Its settings apply to them unless a host sets its own." : "Its hosts each sign in and connect their own way."}`}
onClose={p.onClose}
actions={
<>
@@ -561,12 +574,32 @@ export function GroupPage(p: GroupProps) {
>
}
- aside={uses}
>
+
+ What its hosts use
+ {uses.length === 0 ? (
+ Nothing from the vault: they sign in with a password or the SSH agent, directly.
+ ) : (
+
+ {uses.map((u) => (
+
+ {u.kind}
+
+
+ {u.label}
+
+ {u.sub}
+
+ ))}
+
+ )}
+
Hosts
{members.length ? (
-
+
) : (
None yet. Pick the group in a host's editor.
)}
@@ -574,30 +607,25 @@ export function GroupPage(p: GroupProps) {
{tunnels.length > 0 && (
)}
{(files?.length ?? 0) > 0 && (
Files
-
- {files!.map((f) => (
-
+ {files!.map((f, i) => (
+ }
- title={{f.name} }
- lines={[`${hostLabel(f.hostId)} · ${f.size < 1024 ? `${f.size} B` : f.size < 1048576 ? `${Math.round(f.size / 1024)} KB` : `${(f.size / 1048576).toFixed(1)} MB`}`]}
+ index={i}
+ actions={ attachmentsApi.save(f.id).then((path) => setSaved(`Saved ${path}`), (e) => setError(errorText(e)))}>Save }
>
- attachmentsApi.save(f.id).then((path) => setSaved(`Saved ${path}`), (e) => setError(errorText(e)))}
- >
- Save
-
-
+ } name={f.name} />
+ {hostLabel(f.hostId)} |
+ {sizeText(f.size)} |
+
))}
-
+
)}
{saved && {saved}
}
@@ -618,24 +646,44 @@ interface KeysProps {
onEditIdentity: (id?: string) => void;
hostCas: HostCaView[];
onEditHostCa: (id?: string) => void;
+ /** The tab open; it's part of the view, so closing an editor returns to it. */
+ tab: KeysTab;
+ onTab: (tab: KeysTab) => void;
onChanged: () => void;
onClose: () => void;
}
+export type KeysTab = "keys" | "identities" | "cas";
+
+/** A key's certificate, as a state: none, valid (for how long), soon over, over or unreadable. */
+function CertState({ k }: { k: KeyView }) {
+ if (k.certificateError) return Can't be read ;
+ if (!k.certificate) return None ;
+ const left = k.certificate.validBefore - Date.now() / 1000;
+ if (k.certificate.validBefore >= 2 ** 62) return No expiry ;
+ if (left <= 0) return Expired ;
+ const days = Math.floor(left / 86400);
+ const when = days >= 1 ? `${days} d` : `${Math.max(1, Math.floor(left / 3600))} h`;
+ return days < 7 ? Expires in {when} : Valid {when} ;
+}
+
export function KeysPage(p: KeysProps) {
- // Keys and identities together: a key can go with the identity that uses it.
const sel = useSelection();
+ const tab = p.tab;
+ const [query, setQuery] = useState("");
+ const q = query.trim().toLowerCase();
+ const pickTab = (t: KeysTab) => (sel.stop(), setQuery(""), p.onTab(t));
const keyUse = (k: KeyView) => {
const hosts = p.hosts.filter((h) => h.auth.kind === "key" && h.auth.keyId === k.id).length;
const ids = p.identities.filter((i) => i.auth.kind === "key" && i.auth.keyId === k.id).length;
const parts = [hosts && plural(hosts, "host"), ids && plural(ids, "identity", "identities")].filter(Boolean);
- return parts.length ? `Used by ${parts.join(" and ")}` : "Not used yet";
+ return parts.length ? parts.join(", ") : "Not used yet";
};
const idUse = (it: IdentityView) => {
const hosts = p.hosts.filter((h) => h.identityId === it.id).length;
const groups = p.groups.filter((g) => g.identityId === it.id).length;
const parts = [hosts && plural(hosts, "host"), groups && plural(groups, "group")].filter(Boolean);
- return parts.length ? `Used by ${parts.join(" and ")}` : "Not used yet";
+ return parts.length ? parts.join(", ") : "Not used yet";
};
const authText = (it: IdentityView) =>
it.auth.kind === "key"
@@ -643,115 +691,145 @@ export function KeysPage(p: KeysProps) {
: it.auth.kind === "agent"
? agentDescription(it.auth)
: "password";
+ const keys = q ? p.keys.filter((k) => `${k.label} ${k.fingerprint}`.toLowerCase().includes(q)) : p.keys;
+ const identities = q ? p.identities.filter((i) => `${i.label} ${i.user}`.toLowerCase().includes(q)) : p.identities;
+ const cas = q ? p.hostCas.filter((c) => `${c.label} ${c.hosts.join(" ")}`.toLowerCase().includes(q)) : p.hostCas;
+ const shown = tab === "keys" ? keys : tab === "identities" ? identities : cas;
+ const total = tab === "keys" ? p.keys.length : tab === "identities" ? p.identities.length : p.hostCas.length;
+ const tabs: [KeysTab, string, number][] = [
+ ["keys", "Keys", p.keys.length],
+ ["identities", "Identities", p.identities.length],
+ ["cas", "Host CAs", p.hostCas.length],
+ ];
+ const primary =
+ tab === "keys" ? (
+ p.onOpenKey()}>
+ New key
+
+ ) : tab === "identities" ? (
+ p.onEditIdentity()}>
+ New identity
+
+ ) : (
+ p.onEditHostCa()}>
+ New host CA
+
+ );
return (
- 0} />
- p.onOpenKey()}>
- New key
+ actions={primary}
+ >
+
+ {tabs.map(([t, label, n]) => (
+
pickTab(t)}>
+ {label}
+ {n}
- >
- }
- aside={
-
-
-
Identities
- p.onEditIdentity()}>
- New identity
-
-
-
A user and a key (or password) that several hosts share. Change it once and they all follow.
- {p.identities.length > 0 && (
-
- {p.identities.map((it) => (
-
}
- title={
- <>
- {it.label}
-
- >
- }
- lines={[`${it.user} · ${authText(it)}`, idUse(it)]}
- >
- p.onEditIdentity(it.id)} aria-label={`Edit ${it.label}`}>
- Edit
-
-
+ ))}
+
+ {total === 0 ? (
+ tab === "keys" ? (
+
p.onOpenKey()} />
+ ) : tab === "identities" ? (
+ p.onEditIdentity()} />
+ ) : (
+ p.onEditHostCa()} />
+ )
+ ) : (
+ <>
+
+ ({ id: x.id, vaultId: x.vaultId }))}
+ noun={tab === "keys" ? ["key", "keys"] : tab === "identities" ? ["identity", "identities"] : ["host CA", "host CAs"]}
+ onDelete={bulk.remove}
+ onChanged={p.onChanged}
+ />
+ {tab === "keys" && (
+
+ {keys.map((k, i) => (
+ p.onOpenKey(k.id)} />}>
+ p.onOpenKey(k.id)}
+ title={`Open ${k.label}`}
+ lead={ }
+ name={k.label}
+ beside={
+ <>
+ {k.algorithm.replace(/^ssh-/, "")}
+ {k.encrypted && passphrase }
+
+ >
+ }
+ />
+ {k.fingerprint} |
+ {keyUse(k)} |
+
+
+ |
+
))}
-
+
)}
-
-
Host CAs
- p.onEditHostCa()}>
- New host CA
-
-
- Hosts whose keys a CA signed are trusted without pinning each one (@cert-authority).
- {p.hostCas.length > 0 && (
-
- {p.hostCas.map((ca) => (
-
}
- title={
- <>
- {ca.label}
-
- >
- }
- lines={[{ca.hosts.join(", ")} , {ca.fingerprint} ]}
- >
- p.onEditHostCa(ca.id)} aria-label={`Edit ${ca.label}`}>
- Edit
-
-
+ {tab === "identities" && (
+
+ {identities.map((it, i) => (
+ p.onEditIdentity(it.id)} />}>
+ p.onEditIdentity(it.id)}
+ title={`Edit ${it.label}`}
+ lead={ }
+ name={it.label}
+ beside={ }
+ />
+
+ {it.user}
+ · {authText(it)}
+ |
+ {idUse(it)} |
+
))}
-
+
)}
-
- }
- >
- ({ id: x.id, vaultId: x.vaultId }))}
- noun={["item", "items"]}
- onDelete={bulk.remove}
- onChanged={p.onChanged}
- />
- {p.keys.length === 0 ? (
- p.onOpenKey()} />
- ) : (
-
- {p.keys.map((k) => (
-
}
- title={
- <>
- {k.label}
- {k.algorithm.replace(/^ssh-/, "")}
- {k.encrypted && passphrase }
-
- >
- }
- lines={[{k.fingerprint} , keyUse(k)]}
- >
- p.onOpenKey(k.id)} aria-label={`Open ${k.label}`}>
- Open
-
-
- ))}
-
+ {tab === "cas" && (
+
+ {cas.map((ca, i) => (
+ p.onEditHostCa(ca.id)} />}>
+ p.onEditHostCa(ca.id)}
+ title={`Edit ${ca.label}`}
+ lead={ }
+ name={ca.label}
+ beside={ }
+ />
+ {ca.hosts.join(", ")} |
+ {ca.fingerprint} |
+
+ ))}
+
+ )}
+ {shown.length === 0 && Nothing matches “{query}”.
}
+ >
)}
);
@@ -767,13 +845,22 @@ interface SnippetsProps {
onOpen: (id?: string) => void;
/** From a CSV file. */
onImport: () => void;
- onSend: (body: string, run: boolean) => void;
+ onSend: (body: string, run: boolean, snippet?: SnippetView) => void;
onChanged: () => void;
onClose: () => void;
}
export function SnippetsPage(p: SnippetsProps) {
const sel = useSelection();
+ const [query, setQuery] = useState("");
+ const [filter, setFilter] = useState("all");
+ const q = query.trim().toLowerCase();
+ const tagCounts = new Map();
+ p.snippets.forEach((s) => s.tags.forEach((t) => tagCounts.set(t, (tagCounts.get(t) ?? 0) + 1)));
+ const tags = [...tagCounts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 5);
+ const shown = p.snippets.filter(
+ (s) => (!q || [s.name, s.body, ...s.tags].some((x) => x.toLowerCase().includes(q))) && (filter === "all" || s.tags.includes(filter.slice(4))),
+ );
return (
- 0} />
Import…
@@ -791,54 +877,70 @@ export function SnippetsPage(p: SnippetsProps) {
>
}
>
- ({ id: s.id, vaultId: s.vaultId }))}
- noun={["snippet", "snippets"]}
- onDelete={bulk.remove}
- onChanged={p.onChanged}
- />
{p.snippets.length === 0 ? (
p.onOpen()} />
) : (
-
- {p.snippets.map((sn) => {
- const lines = sn.body.split("\n");
- return (
-
}
- title={
- <>
- {sn.name}
-
- {sn.tags.map((t) => (
-
- {t}
-
- ))}
- >
- }
- lines={[{lines[0]} , lines.length > 1 && `${lines.length} lines`]}
- >
- {p.target && (
- <>
- p.onSend(sn.body, false)}>
- Paste
-
- p.onSend(sn.body, true)}>
- Run
-
- >
- )}
- p.onOpen(sn.id)} aria-label={`Edit ${sn.name}`}>
- Edit
-
-
- );
- })}
-
+ <>
+ 0 && ({ key: `tag:${t}`, label: t, count: n }))} />}
+ />
+ ({ id: s.id, vaultId: s.vaultId }))} noun={["snippet", "snippets"]} onDelete={bulk.remove} onChanged={p.onChanged} />
+ {shown.length === 0 ? (
+ No snippets match{q ? ` “${query}”` : " this filter"}.
+ ) : (
+
+ {shown.map((sn, i) => {
+ const lines = sn.body.split("\n");
+ return (
+
+ {p.target && (
+ <>
+ p.onSend(sn.body, false, sn)}>Paste
+ p.onSend(sn.body, true, sn)}>Run
+ >
+ )}
+ p.onOpen(sn.id)} />
+ >
+ }
+ >
+ p.onOpen(sn.id)}
+ title={`${sn.source ? "Open" : "Edit"} ${sn.name}`}
+ lead={ }
+ name={sn.name}
+ beside={ }
+ sub={sn.source ? `From ${sn.source.label} (read-only)` : undefined}
+ />
+
+ {lines[0]}
+ {lines.length > 1 && {plural(lines.length, "line")} }
+ |
+
+ {sn.tags.slice(0, 2).map((t) => (
+
+ {t}
+
+ ))}
+ {sn.tags.length > 2 && +{sn.tags.length - 2} }
+ |
+
+ );
+ })}
+
+ )}
+ >
)}
);
@@ -850,6 +952,7 @@ interface TunnelsProps {
forwards: ForwardView[];
hosts: HostView[];
vaults: VaultEntry[];
+ live: Map;
pending: Set;
onToggle: (f: ForwardView) => void;
onOpen: (id?: string) => void;
@@ -859,81 +962,95 @@ interface TunnelsProps {
export function TunnelsPage(p: TunnelsProps) {
const sel = useSelection();
+ const [query, setQuery] = useState("");
+ const [filter, setFilter] = useState("all");
+ const q = query.trim().toLowerCase();
const running = p.forwards.filter((f) => f.running).length;
+ const shown = p.forwards.filter((f) => (!q || `${f.label} ${describeForward(f)}`.toLowerCase().includes(q)) && (filter === "all" || f.running));
return (
- 0} />
- p.onOpen()}>
- New tunnel
-
- >
+ p.onOpen()}>
+ New tunnel
+
}
>
- ({ id: f.id, vaultId: f.vaultId }))}
- noun={["tunnel", "tunnels"]}
- onDelete={(ids) => eachOf(ids, forwardsApi.remove)}
- onChanged={p.onChanged}
- />
{p.forwards.length === 0 ? (
p.onOpen()} />
) : (
-
+ <>
+ 0 && }
+ />
+ ({ id: f.id, vaultId: f.vaultId }))} noun={["tunnel", "tunnels"]} onDelete={(ids) => eachOf(ids, forwardsApi.remove)} onChanged={p.onChanged} />
+ {shown.length === 0 ? (
+ No tunnels match{q ? ` “${query}”` : " this filter"}.
+ ) : (
+
+ )}
+ >
)}
);
}
-function TunnelRows(p: Omit & { sel?: Selection }) {
+/** Tunnels, their switch first: on or off is what people come here for. */
+function TunnelTable(p: Omit & { sel?: Selection }) {
return (
-
- )}
+
+ }
+ />
+ {shown.length === 0 ? (
+ No recordings match{q ? ` “${query}”` : narrowed ? " these filters" : ""}.
+ ) : (
+
+ {shown.map((l, i) => (
+
+ play(l.id)}>{loading === l.id ? "Opening…" : "Play"}
+ void save(l.id)}>Save .cast
+ {deleting === l.id ? (
+ void remove(l.id)}>
+ Really delete?
+
+ ) : (
+ setDeleting(l.id)}>
+ Delete
+
+ )}
+ >
+ }
+ >
+ play(l.id)} title={`Play ${l.title}`} name={l.title} sub={l.address} subTone="mono" />
+ | {when(l.startedAt)} |
+ {length(l)} |
+
+ {l.device}
+ {size(l.outputBytes)} printed
+ |
+
+
+ {STATE_NAMES[l.state]}
+
+ |
+
+ ))}
+
+ )}
+ >
+ )}
);
}
diff --git a/apps/desktop/src/Sidebar.tsx b/apps/desktop/src/Sidebar.tsx
index 6e8e192..1d0a3ae 100644
--- a/apps/desktop/src/Sidebar.tsx
+++ b/apps/desktop/src/Sidebar.tsx
@@ -1,6 +1,7 @@
import { useState, type ReactNode } from "react";
import {
ago,
+ totpTitle,
type ForwardView,
type GroupView,
type HostView,
@@ -45,6 +46,8 @@ interface Props {
/** Hosts with an open session. */
live: Map;
onConnect: (host: HostView) => void;
+ /** A host with a session: its tab. */
+ onShowSession: (host: HostView) => void;
onOpenFiles: (host: HostView) => void;
onEditHost: (id?: string) => void;
syncStatus: SyncStatus | null;
@@ -56,6 +59,8 @@ interface Props {
onLock: () => void;
update: UpdateInfo | null;
onOpenAbout: () => void;
+ /** Turn on two-step verification: organizations' vaults wait for it. */
+ onTotp: () => void;
/** Sign in at an organization's identity provider. */
onSso: (need: SsoNeed) => void;
/** An organization's vaults wait for this device's approval. */
@@ -93,6 +98,7 @@ export function Sidebar({
activeHost,
live,
onConnect,
+ onShowSession,
onOpenFiles,
onEditHost,
syncStatus,
@@ -104,6 +110,7 @@ export function Sidebar({
onLock,
update,
onOpenAbout,
+ onTotp,
onSso,
onApproval,
}: Props) {
@@ -153,16 +160,21 @@ export function Sidebar({
);
- const hostRow = (h: HostView, i: number) => {
+ // `session`: a row of the Open list, which goes to the host's tab.
+ const hostRow = (h: HostView, i: number, session = false) => {
const on = selected === h.id || activeHost === h.id;
const state = live.get(h.id);
return (
- onConnect(h)} title={`Connect to ${h.user}@${h.hostname}:${h.port}`}>
-
- {(h.label[0] ?? "?").toUpperCase()}
- {state && }
-
+ (session ? onShowSession(h) : onConnect(h))}
+ title={session ? `Go to ${h.label}'s session` : `Connect to ${h.user}@${h.hostname}:${h.port}`}
+ >
+
{h.label}
@@ -192,6 +204,8 @@ export function Sidebar({
);
};
const ungrouped = shown.filter((h) => !h.groupId || !groups.some((g) => g.id === h.groupId));
+ // Hosts with a session, first: where people go back to most.
+ const sessions = [...live.keys()].map((id) => hosts.find((h) => h.id === id)).filter((h): h is HostView => !!h && (!q || matches(h, q)));
return (
@@ -234,6 +248,12 @@ export function Sidebar({
+ {sessions.length > 0 && (
+ <>
+
+
{sessions.map((h, i) => hostRow(h, i, true))}
+ >
+ )}
onEditHost()}
@@ -284,6 +304,13 @@ export function Sidebar({
Update to {update.version}
)}
+ {!!syncStatus?.totpRequired.length && (
+
+
+ Turn on two-step verification
+
+
+ )}
{syncStatus?.ssoRequired.map((n) => (
void; newLabel: string; extra?: ReactNode }) {
+function Section({ title, onNew, newLabel, extra }: { title: string; onNew?: () => void; newLabel?: string; extra?: ReactNode }) {
return (
{title}
{extra}
-
-
-
-
-
+ {onNew && (
+
+
+
+
+
+ )}
);
}
diff --git a/apps/desktop/src/SnippetCheck.tsx b/apps/desktop/src/SnippetCheck.tsx
new file mode 100644
index 0000000..715c31b
--- /dev/null
+++ b/apps/desktop/src/SnippetCheck.tsx
@@ -0,0 +1,31 @@
+import { useEffect, useRef } from "react";
+import { dateText, type SnippetView } from "./api";
+
+/** A feed's snippet changed since this device last pasted or ran it: what it is now, before it
+ * goes to the terminal. */
+export function SnippetCheckDialog({ snippet, run, onAnswer }: { snippet: SnippetView; run: boolean; onAnswer: (send: boolean) => void }) {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ ref.current?.showModal();
+ }, []);
+
+ return (
+ (e.preventDefault(), onAnswer(false))}>
+ {snippet.name} changed
+
+ Changed by {snippet.source?.label ?? "its feed"} on {dateText(snippet.updatedAt)}, since you last {run ? "ran" : "pasted"} it on this
+ device. It now {run ? "runs" : "pastes"}:
+
+ {snippet.body}
+
+ onAnswer(false)}>
+ Cancel
+
+ onAnswer(true)}>
+ {run ? "Run it" : "Paste it"}
+
+
+
+ );
+}
diff --git a/apps/desktop/src/SnippetPanel.tsx b/apps/desktop/src/SnippetPanel.tsx
index 32c543b..25aa3e6 100644
--- a/apps/desktop/src/SnippetPanel.tsx
+++ b/apps/desktop/src/SnippetPanel.tsx
@@ -10,12 +10,15 @@ interface Props {
onSaved: (s: SnippetView) => void;
onDeleted: () => void;
onCancel: () => void;
- onSend: (body: string, run: boolean) => void;
+ /** `snippet`: the saved one being sent (a feed's may ask first). */
+ onSend: (body: string, run: boolean, snippet?: SnippetView) => void;
}
export function SnippetPanel({ snippet, vaults, targetTitle, onSaved, onDeleted, onCancel, onSend }: Props) {
const [vaultId, setVaultId] = useState(snippet?.vaultId ?? personalVault(vaults));
- const readOnly = isReadOnly(vaults, snippet?.vaultId);
+ // A feed's snippet changes in the feed: here it's read, run or copied.
+ const feed = snippet?.source ?? null;
+ const readOnly = isReadOnly(vaults, snippet?.vaultId) || !!feed;
const [name, setName] = useState(snippet?.name ?? "");
const [body, setBody] = useState(snippet?.body ?? "");
const [tags, setTags] = useState(snippet?.tags.join(", ") ?? "");
@@ -40,22 +43,55 @@ export function SnippetPanel({ snippet, vaults, targetTitle, onSaved, onDeleted,
}
};
+ /** A snippet of one's own with the feed's commands, in the same vault where it can be written. */
+ const copy = async () => {
+ if (!snippet) return;
+ setBusy(true);
+ setError("");
+ try {
+ const into = isReadOnly(vaults, snippet.vaultId) ? personalVault(vaults) : snippet.vaultId;
+ onSaved(await snippets.save({ vaultId: into || undefined, name: `${snippet.name} (copy)`, body: snippet.body, tags: snippet.tags }));
+ } catch (err) {
+ setError(errorText(err));
+ } finally {
+ setBusy(false);
+ }
+ };
+
return (
diff --git a/apps/desktop/src/SyncPanel.tsx b/apps/desktop/src/SyncPanel.tsx
index 398634c..b0722e4 100644
--- a/apps/desktop/src/SyncPanel.tsx
+++ b/apps/desktop/src/SyncPanel.tsx
@@ -5,10 +5,11 @@ import {
platformName,
errorText,
isTotpRequired,
- openLink,
ownsTeam,
sync,
+ totpTitle,
type OrgInfo,
+ type SsoNeed,
type SyncAccount,
type SyncDevice,
type SyncPlan,
@@ -20,6 +21,7 @@ import {
import { Page } from "./Page";
import { HOSTED_SERVER, SyncServerField } from "./SyncServerField";
import { isTotpCode, TotpCode } from "./TotpCode";
+import { TermsCheck, TermsLinks } from "./Terms";
import { RecoveryCode } from "./VaultGate";
import { mobileOs } from "./mobile/useMobile";
@@ -30,9 +32,11 @@ interface Props {
onChanged: () => void;
onClose: () => void;
onOpenTeams: () => void;
+ /** Opened to turn on two-step verification (teams wait for it). */
+ startTotp?: boolean;
}
-export function SyncPanel({ status, orgs, onChanged, onClose, onOpenTeams }: Props) {
+export function SyncPanel({ status, orgs, onChanged, onClose, onOpenTeams, startTotp }: Props) {
// The address of the account just deleted from here.
const [deleted, setDeleted] = useState("");
// The account this device just joined: what that did to its master password is said once more.
@@ -50,6 +54,8 @@ export function SyncPanel({ status, orgs, onChanged, onClose, onOpenTeams }: Pro
!status.signedOut && (
o.role === "owner")}
onChanged={onChanged}
onDeleted={onDeleted}
@@ -74,8 +80,42 @@ export function SyncPanel({ status, orgs, onChanged, onClose, onOpenTeams }: Pro
);
}
-/** First run: the same setup, offered once in a dialog right after the vault is created. `later`
- * says where Sync is found afterwards. */
+// A vault created without sync gets the offer once, a week on (the first screen had New account
+// and Sign in already): when, on this device.
+const OFFER_AT = "morphgate.sync-offer-at";
+const WEEK = 7 * 24 * 60 * 60 * 1000;
+
+/** When `SyncOffer` shows: a week after a vault was created without sync, then never again. */
+export const syncOfferTime = {
+ /** A vault without sync, created just now. */
+ schedule() {
+ try {
+ localStorage.setItem(OFFER_AT, String(Date.now() + WEEK));
+ } catch {
+ // No offer, then: Sync stays in the sidebar.
+ }
+ },
+ /** Whether its week is up. */
+ due() {
+ try {
+ const at = Number(localStorage.getItem(OFFER_AT));
+ return at > 0 && at <= Date.now();
+ } catch {
+ return false;
+ }
+ },
+ /** Answered, or not wanted any more (sync is on, or the device started with an account). */
+ forget() {
+ try {
+ localStorage.removeItem(OFFER_AT);
+ } catch {
+ // Nothing to forget.
+ }
+ },
+};
+
+/** The same setup in a dialog, offered once a week after a vault was created without sync
+ * (`syncOfferTime`). `later` says where Sync is found afterwards. */
export function SyncOffer({ later, onDone }: { later: string; onDone: (enabled: boolean) => void }) {
const ref = useRef(null);
const [joinedAs, setJoinedAs] = useState("");
@@ -464,32 +504,6 @@ function Setup({ onChanged, onJoined }: { onChanged: () => void; onJoined: (emai
);
}
-/** The server's terms of use and privacy policy, each opening in the browser. */
-function TermsLinks({ terms }: { terms: SyncTerms }) {
- return (
- <>
- void openLink(terms.termsUrl)}>
- terms of use
- {" "}
- and the{" "}
- void openLink(terms.privacyUrl)}>
- privacy policy
-
- >
- );
-}
-
-function TermsCheck({ terms, accepted, onChange }: { terms: SyncTerms; accepted: boolean; onChange: (v: boolean) => void }) {
- return (
-
- onChange(e.target.checked)} />
-
- I accept the .
-
-
- );
-}
-
/** The server's terms changed since this account accepted them. */
function TermsUpdate({ terms, onDone }: { terms: SyncTerms; onDone: () => void }) {
const { busy, error, run } = useStep();
@@ -610,12 +624,17 @@ function planText(p: SyncPlan): string {
* again after each sync round (`round`): a plan bought or ended meanwhile shows. */
function Account({
round,
+ totpRequired,
+ startTotp,
owned,
onChanged,
onDeleted,
onOpenTeams,
}: {
round: number | null;
+ /** Teams whose vaults wait until the account has two-step verification. */
+ totpRequired: SsoNeed[];
+ startTotp?: boolean;
owned: OrgInfo[];
onChanged: () => void;
onDeleted: (email: string) => void;
@@ -624,7 +643,7 @@ function Account({
const [account, setAccount] = useState(null);
const [error, setError] = useState("");
const [saved, setSaved] = useState("");
- const [mode, setMode] = useState<"" | "totp-on" | "totp-off" | "email" | "delete">("");
+ const [mode, setMode] = useState<"" | "totp-on" | "totp-off" | "email" | "delete">(startTotp ? "totp-on" : "");
const exportData = () => {
setError("");
setSaved("");
@@ -644,6 +663,8 @@ function Account({
const cancel = () => setMode("");
if (!account) return error ? {error}
: null;
+ // Asked to turn it on, but it's on already (from another device, say).
+ const shown = mode === "totp-on" && account.totpEnabled ? "" : mode;
const facts = account.plan || account.emailVerified === false || account.totp;
return (
@@ -671,19 +692,20 @@ function Account({
)}
{error &&
{error}
}
- {account.termsToAccept && mode === "" &&
}
+ {account.termsToAccept && shown === "" &&
}
{saved && (
Saved {saved}: what the server keeps about your account. Your hosts and keys aren't in it; the server can't read them.
)}
- {mode === "totp-on" &&
}
- {mode === "totp-off" &&
}
- {mode === "email" &&
}
- {mode === "delete" && (
+ {totpRequired.length > 0 && !account.totpEnabled &&
{totpTitle(totpRequired)}.
}
+ {shown === "totp-on" &&
}
+ {shown === "totp-off" &&
}
+ {shown === "email" &&
}
+ {shown === "delete" && (
onDeleted(account.email)} onCancel={cancel} onOpenTeams={onOpenTeams} />
)}
- {mode === "" && (
+ {shown === "" && (
{account.totp && (
setMode(account.totpEnabled ? "totp-off" : "totp-on")}>
@@ -708,7 +730,7 @@ function Account({
)}
- {mode === "" && account.totp && !account.totpEnabled && (
+ {shown === "" && account.totp && !account.totpEnabled && (
With two-step verification, signing in on a new device also needs a code from an authenticator app.
)}
diff --git a/apps/desktop/src/SyncServerField.tsx b/apps/desktop/src/SyncServerField.tsx
index 92bb4f5..db4acf9 100644
--- a/apps/desktop/src/SyncServerField.tsx
+++ b/apps/desktop/src/SyncServerField.tsx
@@ -1,4 +1,4 @@
-import { useState, type ReactNode } from "react";
+import { useEffect, useState, type ReactNode } from "react";
/** MorphGate's hosted sync service, the default choice. */
export const HOSTED_SERVER = "https://morphgate.com";
@@ -28,6 +28,13 @@ export function SyncServerField({ value, onChange, action }: Props) {
setOwn(mine);
onChange(mine ? address : HOSTED_SERVER);
};
+ // Set from outside (the server an invite names): shown as chosen.
+ useEffect(() => {
+ if (value === (own ? address : HOSTED_SERVER)) return;
+ const mine = value !== HOSTED_SERVER;
+ setOwn(mine);
+ if (mine) setAddress(value);
+ }, [value]);
return (
Sync server
diff --git a/apps/desktop/src/TeamsPanel.tsx b/apps/desktop/src/TeamsPanel.tsx
index 441b029..fb5927a 100644
--- a/apps/desktop/src/TeamsPanel.tsx
+++ b/apps/desktop/src/TeamsPanel.tsx
@@ -1,4 +1,11 @@
-import { Fragment, useCallback, useEffect, useState, type FormEvent } from "react";
+// Teams: the teams this account is in, each team's page, and joining one.
+//
+// A team's page puts what needs you first (a member to confirm, a device to approve, a key to
+// rotate), then tabs: Members, Vaults, your SSH keys, and for owners and admins Settings and
+// Activity. Lists are tables (ListParts.tsx), as on the other pages; inviting people is the
+// page's one primary action.
+
+import { Fragment, useCallback, useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
import {
ACCESS_NAMES,
ROLE_NAMES,
@@ -28,40 +35,52 @@ import {
type SharingStatus,
type SsoView,
type DeviceRequest,
+ type SyncStatus,
type VaultAccess,
type VaultEntry,
} from "./api";
+import { Icon } from "./Icon";
+import { Cell, Chip, NameCell, Table, TextAction, TRow } from "./ListParts";
import { Page } from "./Page";
import { mobileOs } from "./mobile/useMobile";
+import { copyText } from "./clipboard";
interface Props {
vaults: VaultEntry[];
orgs: OrgInfo[];
/** SSH keys in the vault (the personal ones can be published to an organization). */
keys: KeyView[];
+ /** What sync knows each team waits on: sign-ins, approvals, keys to publish. */
+ syncStatus: SyncStatus | null;
/** Reloads organizations, vaults and items. */
onChanged: () => Promise
;
onClose: () => void;
/** Sign in at an organization's identity provider. */
onSso: (orgId: string, orgName: string) => void;
+ /** Organizations whose vaults wait until the account has two-step verification. */
+ totpRequired: string[];
+ /** Turn on two-step verification. */
+ onTotp: () => void;
/** From a link: an invite to look up, or the team to open. */
invite?: string;
openOrg?: string;
}
const ACCESS_LEVELS: Access[] = ["read", "write", "manage"];
+const plural = (n: number, one: string, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
-export function TeamsPanel({ vaults, orgs, keys, onChanged, onClose, onSso, invite, openOrg }: Props) {
+export function TeamsPanel({ vaults, orgs, keys, syncStatus, onChanged, onClose, onSso, totpRequired, onTotp, invite, openOrg }: Props) {
const personal = vaults.find((v) => v.personal)?.id;
const personalKeys = keys.filter((k) => k.vaultId === personal);
const [status, setStatus] = useState(null);
const [error, setError] = useState("");
const [open, setOpen] = useState(openOrg ?? null);
+ const [creating, setCreating] = useState(false);
useEffect(() => {
teams.status().then(setStatus, (e) => setError(errorText(e)));
}, []);
const org = orgs.find((o) => o.id === open);
- const opened = (id: string) => void onChanged().then(() => setOpen(id));
+ const opened = (id: string) => void onChanged().then(() => (setCreating(false), setOpen(id)));
if (org) {
return (
@@ -77,82 +96,94 @@ export function TeamsPanel({ vaults, orgs, keys, onChanged, onClose, onSso, invi
onChanged={onChanged}
onGone={() => void onChanged().then(() => setOpen(null))}
onSso={() => onSso(org.id, org.name)}
+ totpRequired={totpRequired.includes(org.id)}
+ onTotp={onTotp}
/>
);
}
+ // What each team waits on from this account or device, from what's known here.
+ const needsOf = (o: OrgInfo): string[] => {
+ if (o.status === "accepted") return [];
+ const out: string[] = [];
+ if (totpRequired.includes(o.id)) out.push("Turn on two-step verification");
+ if (syncStatus?.ssoRequired.some((n) => n.orgId === o.id)) out.push("Sign in with single sign-on");
+ const held = syncStatus?.approvalRequired.find((n) => n.orgId === o.id);
+ if (held) out.push(held.denied ? "This device was denied" : "This device waits for approval");
+ const waiting = syncStatus?.approvalsWaiting.find((w) => w.orgId === o.id)?.count ?? 0;
+ if (waiting) out.push(`${plural(waiting, "device")} to approve`);
+ if (syncStatus?.memberKeysDue.some((d) => d.orgId === o.id)) out.push("Publish your SSH keys");
+ if (vaults.some((v) => v.orgId === o.id && v.access === "manage" && v.rotationDue)) out.push("Rotate a vault's key");
+ return out;
+ };
+
return (
- {status.fingerprint && (
-
-
Your fingerprint
-
-
- Before an admin gives you access to a team's vaults, they compare this with you on a call or in person. It
- proves that the keys the server showed them are really yours.
-
-
- )}
-
- >
- ) : undefined
+ actions={
+ status?.available && (
+ setCreating(true)} disabled={creating}>
+ New team
+
+ )
}
>
{error && {error}
}
{!status && !error && Loading…
}
{status && !status.available && {status.reason}
}
- {(orgs.length > 0 || status?.available) && (
-
-
Organizations
- {orgs.length === 0 &&
None yet. Create one, or join with an invite someone sent you.
}
- {orgs.length > 0 && (
-
- {orgs.map((o) => {
- const count = vaults.filter((v) => v.orgId === o.id && !v.directory).length;
+ {creating && setCreating(false)} />}
+ {orgs.length === 0
+ ? status?.available && !creating && No teams yet. Make one, or join with an invite someone sent you.
+ : (
+
+ {orgs.map((o, i) => {
+ const names = vaults.filter((v) => v.orgId === o.id && !v.directory).map((v) => v.name ?? "Shared vault");
+ const needs = needsOf(o);
return (
-
-
- {o.name}
- {ROLE_NAMES[o.role]}
-
- {o.status === "accepted"
- ? "Waiting for an admin to confirm you"
- : `${count} vault${count === 1 ? "" : "s"} you can open`}
-
-
- setOpen(o.id)}>
- Open
-
-
+ setOpen(o.id)}>Open}>
+ setOpen(o.id)} title={`Open ${o.name}`} lead={ } name={o.name} />
+ {o.status === "accepted" ? Joined : ROLE_NAMES[o.role]} |
+ {names.length ? names.join(", ") : None yet } |
+
+ {o.status === "accepted" ? (
+ An admin confirms you next
+ ) : needs.length ? (
+ needs.map((n) => (
+
+ {n}
+
+ ))
+ ) : (
+ Nothing
+ )}
+ |
+
);
})}
-
+
+ )}
+ {status?.available && (
+
+
+ {status.fingerprint && (
+
+
Your fingerprint
+
+
+ Before an admin gives you a team's vaults, they compare this with you on a call or in person. It proves that the keys
+ the server showed them are yours.
+
+
)}
)}
- {status?.available && }
);
}
-/** A fingerprint in groups of four, easy to read out loud. */
-function Fingerprint({ value }: { value: string }) {
- return (
-
- {value.split("-").map((g, i) => (
- {g}
- ))}
-
- );
-}
-
-function CreateOrg({ onCreated }: { onCreated: (id: string) => void }) {
+function CreateOrg({ onCreated, onCancel }: { onCreated: (id: string) => void; onCancel: () => void }) {
const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
@@ -170,18 +201,19 @@ function CreateOrg({ onCreated }: { onCreated: (id: string) => void }) {
);
};
return (
-
- New organization
-
- setName(e.target.value)} placeholder="Acme Ops" aria-label="Organization name" />
-
- Create
-
-
- You'll be its owner. Then create vaults in it and invite people.
- {error && {error}
}
+
+ New team
+ setName(e.target.value)} placeholder="Acme Ops" aria-label="Team name" autoFocus />
+
+ Cancel
+
+
+ Create team
+
+ You'll be its owner. Then create vaults in it and invite people.
+ {error && {error}
}
{limit && (
-
+
{limit.charAt(0).toUpperCase() + limit.slice(1)}.
{!mobileOs && (
@@ -200,88 +232,6 @@ function CreateOrg({ onCreated }: { onCreated: (id: string) => void }) {
);
}
-/** `invite`: from a link, looked up at once (joining still waits for the fingerprint check). */
-function JoinOrg({ onJoined, invite }: { onJoined: (id: string) => void; invite?: string }) {
- const [token, setToken] = useState(invite ?? "");
- const [preview, setPreview] = useState
(null);
- const [matches, setMatches] = useState(false);
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState("");
-
- const look = (code: string) => {
- if (!code.trim()) return;
- setBusy(true);
- setError("");
- teams.preview(code.trim()).then(
- (p) => (setPreview(p), setMatches(false), setBusy(false)),
- (err) => (setError(errorText(err)), setBusy(false)),
- );
- };
- const lookUp = (e: FormEvent) => {
- e.preventDefault();
- look(token);
- };
- useEffect(() => {
- if (invite) look(invite);
- }, [invite]);
- const join = () => {
- if (!preview) return;
- setBusy(true);
- setError("");
- teams.accept(token.trim(), preview.ownerFingerprint).then(
- (id) => (setToken(""), setPreview(null), setBusy(false), onJoined(id)),
- (err) => (setError(errorText(err)), setBusy(false)),
- );
- };
-
- return (
-
- Join with an invite
- {!preview ? (
-
- setToken(e.target.value)}
- placeholder="Invite code"
- aria-label="Invite code"
- spellCheck={false}
- autoCapitalize="off"
- />
-
- Look up
-
-
- ) : (
-
-
- {preview.orgName} invites {preview.email} as {ROLE_NAMES[preview.role].toLowerCase()} (sent by{" "}
- {preview.inviterEmail}).
-
-
- Ask the owner (or whoever invited you: it's in their member list) to read you the owner's fingerprint. Join only if
- it matches this one:
-
-
-
- setMatches(e.target.checked)} />
- It matches the fingerprint they read to me
-
-
- setPreview(null)}>
- Cancel
-
-
- Join {preview.orgName}
-
-
-
- )}
- {error && {error}
}
-
- );
-}
-
interface OrgProps {
org: OrgInfo;
vaults: VaultEntry[];
@@ -296,9 +246,14 @@ interface OrgProps {
onGone: () => void;
/** Sign in at the organization's identity provider. */
onSso: () => void;
+ /** Its vaults wait until the account has two-step verification. */
+ totpRequired: boolean;
+ onTotp: () => void;
}
-function OrgView({ org, vaults, personalKeys, fingerprint, me, onBack, onClose, onChanged, onGone, onSso }: OrgProps) {
+type OrgTab = "members" | "vaults" | "keys" | "settings" | "activity";
+
+function OrgView({ org, vaults, personalKeys, fingerprint, me, onBack, onClose, onChanged, onGone, onSso, totpRequired, onTotp }: OrgProps) {
const [members, setMembers] = useState(null);
const [memberKeys, setMemberKeys] = useState([]);
const shared = vaults.filter((v) => !v.directory);
@@ -310,7 +265,12 @@ function OrgView({ org, vaults, personalKeys, fingerprint, me, onBack, onClose,
useEffect(loadKeys, [loadKeys, vaults]);
const [invites, setInvites] = useState([]);
const [details, setDetails] = useState(null);
+ const [requests, setRequests] = useState([]);
const [offboarding, setOffboarding] = useState(null);
+ // The member whose fingerprint is being compared (to confirm them, or certify them again).
+ const [confirming, setConfirming] = useState(null);
+ const [inviting, setInviting] = useState(false);
+ const [newVault, setNewVault] = useState(false);
// Plans enforced and no usable plan (usable as the server counts it: a trial, an active plan or
// its grace period): nothing in the team changes until it has one. Removing people,
// transferring and deleting the team stay possible.
@@ -321,14 +281,19 @@ function OrgView({ org, vaults, personalKeys, fingerprint, me, onBack, onClose,
const admin = confirmed && (org.role === "owner" || org.role === "admin");
const owner = org.role === "owner";
const canCreateVaults = admin || (confirmed && !!details?.policies.membersCanCreateVaults);
+ const [tab, setTab] = useState(admin ? "members" : "vaults");
+ const loadRequests = useCallback(() => {
+ if (confirmed) teams.deviceRequests(org.id).then(setRequests, () => {});
+ }, [org.id, confirmed]);
const load = useCallback(() => {
// Only confirmed members see the member list.
if (!confirmed) return;
teams.members(org.id).then(setMembers, (e) => setError(errorText(e)));
teams.details(org.id).then(setDetails, (e) => setError(errorText(e)));
if (admin) teams.invites(org.id).then(setInvites, (e) => setError(errorText(e)));
- }, [org.id, confirmed, admin]);
+ loadRequests();
+ }, [org.id, confirmed, admin, loadRequests]);
useEffect(load, [load]);
// Back from the browser (a plan bought, say): the plan again.
useEffect(() => {
@@ -360,50 +325,71 @@ function OrgView({ org, vaults, personalKeys, fingerprint, me, onBack, onClose,
);
}
- const lead = (details?.plansOffered || (confirmed && details?.ssoOffered)) && (
- <>
- {details?.plansOffered && (
- m.status === "confirmed").length ?? null}
- portal={owner && details.portal && !mobileOs}
- />
- )}
- {confirmed && details?.ssoOffered && }
- >
- );
+ // The owner, on desktop, chooses or manages the team's plan in the account portal (servers with plans).
+ const portal = owner && !!details?.plansOffered && details.portal && !mobileOs;
+ const billing = () => void sync.openPortal(`/account/team/${org.id}/billing`).catch((e) => setError(errorText(e)));
+
+ const published = (account: string) => memberKeys.filter((k) => k.accountId === account && k.verified).length;
+ const keysRequired = details?.policies.requireMemberKeys === true;
+ const keysDue = confirmed && keysRequired && hasDirectory && !!me && published(me) === 0 && personalKeys.length > 0;
+ const confirm = (accountId: string) => (setTab("members"), setConfirming(accountId));
+
+ const tabs: [OrgTab, string, number | null][] = !confirmed
+ ? []
+ : admin
+ ? [
+ ["members", "Members", members?.length ?? null],
+ ["vaults", "Vaults", shared.length],
+ ["keys", "Your SSH keys", null],
+ ["settings", "Settings", null],
+ ["activity", "Activity", null],
+ ]
+ : [
+ ["vaults", "Vaults", shared.length],
+ ["members", "Members", members?.length ?? null],
+ ["keys", "Your SSH keys", null],
+ ];
return (
{ROLE_NAMES[org.role]}}
- lead={lead || undefined}
- aside={
- admin ? (
+ lead={
+ (details?.plansOffered || (confirmed && details?.ssoOffered)) && (
<>
-
- m.status === "confirmed").length ?? null}
/>
-
- {details && (
-
-
-
)}
-
- {owner && }
+ {confirmed && details?.ssoOffered && }
+ >
+ )
+ }
+ actions={
+ (portal || (confirmed && canCreateVaults) || admin) && (
+ <>
+ {confirmed && canCreateVaults && (
+ (setTab("vaults"), setNewVault(true))}>
+ New vault
+
+ )}
+ {portal && (
+
+ {details?.plan ? "Manage plan ↗" : "Choose a plan ↗"}
+
+ )}
+ {admin && (
+ setInviting(true)}>
+ Invite people
+
+ )}
>
- ) : undefined
+ )
}
>
{!confirmed && (
@@ -421,269 +407,369 @@ function OrgView({ org, vaults, personalKeys, fingerprint, me, onBack, onClose,
leaving still work.
)}
- {/* Locked: giving access and new vaults wait for a plan; taking access away and rotating don't. */}
-
-
Vaults
- {shared.length === 0 && (
-
- {canCreateVaults ? "No vaults yet. Create one, then give members access." : "You can't open any of its vaults yet."}
-
- )}
- {vaults.length > 0 && (
-
- {vaults.map((v) => (
-
- ))}
-
- )}
- {canCreateVaults && (
-
-
-
- )}
-
-
{confirmed && (
-
- k.mine)} ready={hasDirectory} onChanged={loadKeys} />
-
+ setTab("keys")}
+ onChanged={() => (loadRequests(), changed())}
+ />
+ )}
+ {tabs.length > 0 && (
+
+ {tabs.map(([t, label, n]) => (
+ setTab(t)}>
+ {label}
+ {n !== null && {n} }
+
+ ))}
+
)}
- {confirmed && details && (
+ {confirmed && tab === "members" && (
+ <>
+
+
+ >
+ )}
+ {confirmed && tab === "vaults" && (
+
+ )}
+ {confirmed && tab === "keys" && (
-
+ k.mine)} ready={hasDirectory} waiting={totpRequired} onChanged={loadKeys} />
)}
- {confirmed && (
-
+
+
+
+ {owner && }
+ >
+ )}
+ {admin && tab === "activity" && }
+ {inviting && (
+ setInviting(false)}
onChanged={changed}
- onGone={onGone}
- onOffboard={setOffboarding}
/>
)}
);
}
-function Leave({ org, me, onGone }: { org: OrgInfo; me: string; onGone: () => void }) {
- const [sure, setSure] = useState(false);
+/** What waits for this account in the team: a member to confirm or certify again, a device to
+ * approve, a key to rotate, two-step verification, SSH keys to publish. Each with its action. */
+function NeedsYou({
+ org,
+ admin,
+ members,
+ requests,
+ vaults,
+ totpRequired,
+ keysDue,
+ onTotp,
+ onConfirm,
+ onKeys,
+ onChanged,
+}: {
+ org: OrgInfo;
+ admin: boolean;
+ members: MemberView[];
+ requests: DeviceRequest[];
+ vaults: VaultEntry[];
+ totpRequired: boolean;
+ keysDue: boolean;
+ onTotp: () => void;
+ onConfirm: (accountId: string) => void;
+ onKeys: () => void;
+ onChanged: () => void;
+}) {
+ const [busy, setBusy] = useState("");
const [error, setError] = useState("");
+ const run = (what: string, p: Promise) => {
+ setBusy(what);
+ setError("");
+ p.then(
+ () => (setBusy(""), onChanged()),
+ (e) => (setError(errorText(e)), setBusy("")),
+ );
+ };
+ const rows: { key: string; text: ReactNode; actions: ReactNode }[] = [];
+ if (totpRequired) {
+ rows.push({
+ key: "totp",
+ text: <>{org.name} requires two-step verification: its vaults open on your devices once your account has it.>,
+ actions: (
+
+ Turn on two-step verification
+
+ ),
+ });
+ }
+ if (admin) {
+ for (const m of members.filter((m) => m.status === "accepted")) {
+ rows.push({
+ key: `confirm:${m.accountId}`,
+ text: (
+ <>
+ {m.email} joined. Compare their fingerprint with them on a call or in person, then confirm them
+ {m.pendingVaults.length > 0 ? ": they get the vaults their invite offered." : "."}
+ >
+ ),
+ actions: (
+ onConfirm(m.accountId)}>
+ Confirm {m.email.split("@")[0]}…
+
+ ),
+ });
+ }
+ for (const m of members.filter((m) => m.status === "confirmed" && !m.verified && !m.me)) {
+ rows.push({
+ key: `verify:${m.accountId}`,
+ text: (
+ <>
+ Certify {m.email} again: the admin who confirmed them left, so devices that never verified them can't yet.
+ >
+ ),
+ actions: (
+ onConfirm(m.accountId)}>
+ Verify…
+
+ ),
+ });
+ }
+ }
+ for (const r of requests.filter((r) => r.status === "pending" && !r.thisDevice && (admin || r.mine))) {
+ const whose = r.mine ? "Your" : `${r.email ?? "A member"}'s`;
+ rows.push({
+ key: `device:${r.accountId}/${r.deviceId}`,
+ text: (
+ <>
+
+ {whose} new device{r.name ? `, ${r.name}` : ""}
+
+ {r.platform ? ` (${platformName(r.platform)})` : ""}, waits for approval to open the team's vaults.
+ >
+ ),
+ actions: (
+ <>
+ run(`a:${r.deviceId}`, teams.decideDevice(org.id, r.accountId, r.deviceId, true))}
+ >
+ Approve
+
+ run(`d:${r.deviceId}`, teams.decideDevice(org.id, r.accountId, r.deviceId, false))}
+ >
+ Deny
+
+ >
+ ),
+ });
+ }
+ for (const v of vaults.filter((v) => v.access === "manage" && v.rotationDue)) {
+ rows.push({
+ key: `rotate:${v.id}`,
+ text: (
+ <>
+ {v.name ?? "A vault"}'s key should be rotated: someone lost access since it was made. A new key keeps them out of what's
+ added next.
+ >
+ ),
+ actions: (
+ run(`r:${v.id}`, teams.rotate(v.id))}>
+ {busy === `r:${v.id}` ? "Rotating…" : "Rotate key"}
+
+ ),
+ });
+ }
+ if (keysDue) {
+ rows.push({
+ key: "keys",
+ text: <>{org.name} asks members to publish the SSH keys they use on its servers, so that leaving can remove them.>,
+ actions: (
+
+ Publish a key
+
+ ),
+ });
+ }
+ if (rows.length === 0 && !error) return null;
return (
- <>
- (sure ? teams.remove(org.id, me).then(onGone, (e) => setError(errorText(e))) : setSure(true))}
- >
- {sure ? `Really leave ${org.name}?` : "Leave"}
-
+
+ {rows.map((r) => (
+
+
+ {r.text}
+ {r.actions}
+
+ ))}
{error && {error}
}
- >
+
);
}
-function VaultRow({ vault, members, locked, onChanged }: { vault: VaultEntry; members: MemberView[]; locked: boolean; onChanged: () => void }) {
- const [editing, setEditing] = useState(false);
- const [busy, setBusy] = useState(false);
+/** The team's vaults: what you can do in each, its key, and (for those you manage) who can open it. */
+function VaultsTab({
+ vaults,
+ members,
+ orgId,
+ canCreate,
+ creating,
+ onCreating,
+ locked,
+ onChanged,
+}: {
+ vaults: VaultEntry[];
+ members: MemberView[];
+ orgId: string;
+ canCreate: boolean;
+ /** New vault (the page's action) opened its bar. */
+ creating: boolean;
+ onCreating: (on: boolean) => void;
+ locked: boolean;
+ onChanged: () => void;
+}) {
+ const [editing, setEditing] = useState(null);
+ const [busy, setBusy] = useState(null);
const [error, setError] = useState("");
- const manage = vault.access === "manage";
- const rotate = () => {
- setBusy(true);
+ const shared = vaults.filter((v) => !v.directory);
+ const rotate = (v: VaultEntry) => {
+ setBusy(v.id);
setError("");
- teams.rotate(vault.id).then(
- () => (setBusy(false), onChanged()),
- (e) => (setError(errorText(e)), setBusy(false)),
+ teams.rotate(v.id).then(
+ () => (setBusy(null), onChanged()),
+ (e) => (setError(errorText(e)), setBusy(null)),
);
};
+ // Shared vaults by name, then the member key directory.
+ const rows = [...shared, ...vaults.filter((v) => v.directory)];
return (
<>
-
-
- {vault.name ?? "Shared vault"}
- {vault.directory ? "Every member" : ACCESS_NAMES[vault.access]}
-
- {vault.directory
- ? `Members' SSH public keys: ${vault.items} published`
- : `${vault.items} item${vault.items === 1 ? "" : "s"}`}
- {vault.rotationDue && " · someone lost access since the key was made"}
-
-
- {manage && vault.rotationDue && (
-
- {busy ? "Rotating…" : "Rotate key"}
-
- )}
- {manage && !vault.directory && (
- setEditing(!editing)}>
- {editing ? "Done" : "Access"}
-
- )}
-
- {error && (
-
- {error}
-
+ {creating && (onCreating(false), onChanged())} onCancel={() => onCreating(false)} />}
+ {shared.length === 0 && !creating && (
+ {canCreate ? "No vaults yet. Create one (New vault), then give members access." : "You can't open any of its vaults yet."}
)}
- {editing && (
-
-
-
- )}
- >
- );
-}
-
-function AccessSelect({ value, onChange, label, disabled }: { value: Access; onChange: (a: Access) => void; label: string; disabled?: boolean }) {
- return (
- onChange(e.target.value as Access)} aria-label={label} disabled={disabled}>
- {ACCESS_LEVELS.map((a) => (
-
- {ACCESS_NAMES[a]}
-
- ))}
-
- );
-}
-
-function AccessEditor({ vault, members, locked, onChanged }: { vault: VaultEntry; members: MemberView[]; locked: boolean; onChanged: () => void }) {
- const [list, setList] = useState(null);
- const [add, setAdd] = useState("");
- const [addAccess, setAddAccess] = useState("write");
- const [revoked, setRevoked] = useState(false);
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState("");
-
- const load = useCallback(() => {
- teams.access(vault.id).then(setList, (e) => setError(errorText(e)));
- }, [vault.id]);
- useEffect(load, [load]);
-
- const act = (p: Promise, after?: () => void) => {
- setBusy(true);
- setError("");
- p.then(
- () => (after?.(), setBusy(false), load(), onChanged()),
- (e) => (setError(errorText(e)), setBusy(false)),
- );
- };
- const candidates = members.filter(
- (m) => m.status === "confirmed" && m.role === "member" && !list?.some((a) => a.accountId === m.accountId),
- );
-
- return (
-
- {list === null && !error &&
Loading…
}
- {list?.map((a) => (
-
-
{a.email || a.accountId}
- {a.implicit ? (
-
{ACCESS_NAMES.manage} (owner or admin)
- ) : (
- <>
-
act(teams.setAccess(vault.id, a.accountId, x))} />
- act(teams.revokeAccess(vault.id, a.accountId), () => setRevoked(true))}
- >
- Remove
-
- >
- )}
-
- ))}
- {candidates.length > 0 && (
-
-
setAdd(e.target.value)} aria-label="Member to give access" disabled={locked}>
- Give a member access…
- {candidates.map((m) => (
-
- {m.email}
-
- ))}
-
-
-
act(teams.setAccess(vault.id, add, addAccess), () => setAdd(""))}
- >
- Add
-
-
- )}
- {revoked && (
-
- They can't sync this vault anymore, but what they already had stays on their devices. Rotate the key (the button
- appears after the next sync) so they can't read anything added from now on, and change the passwords and keys they
- could see.
-
+ {rows.length > 0 && (
+
+ {rows.map((v, i) => {
+ const manage = v.access === "manage";
+ return (
+
+
+ {manage && v.rotationDue && (
+ rotate(v)}>
+ {busy === v.id ? "Rotating…" : "Rotate key"}
+
+ )}
+ {manage && !v.directory && (
+ setEditing(editing === v.id ? null : v.id)}>{editing === v.id ? "Done" : "Access"}
+ )}
+ >
+ }
+ >
+ }
+ name={v.name ?? "Shared vault"}
+ sub={v.directory ? "Members' SSH public keys" : undefined}
+ />
+ {v.items} |
+ {v.directory ? Every member : ACCESS_NAMES[v.access]} |
+
+ {v.rotationDue ? (
+ {manage ? "Rotate" : "Rotation due"}
+ ) : (
+ Current
+ )}
+ |
+
+ {editing === v.id && (
+
+ )}
+
+ );
+ })}
+
)}
- {members.some((m) => m.status === "accepted") &&
Members waiting for confirmation can be added once confirmed.
}
- {error &&
{error}
}
-
- );
-}
-
-function NewVault({ orgId, onCreated }: { orgId: string; onCreated: () => void }) {
- const [name, setName] = useState("");
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState("");
- const submit = (e: FormEvent) => {
- e.preventDefault();
- if (!name.trim()) return;
- setBusy(true);
- setError("");
- teams.createVault(orgId, name.trim()).then(
- () => (setName(""), setBusy(false), onCreated()),
- (err) => (setError(errorText(err)), setBusy(false)),
- );
- };
- return (
-
-
- setName(e.target.value)} placeholder="Staging" aria-label="New vault name" />
-
- New vault
-
-
{error && {error}
}
-
+ >
);
}
interface MembersProps {
org: OrgInfo;
members: MemberView[] | null;
- /** Keys published in the directory, to show who published theirs. */
- memberKeys: MemberKeyView[];
+ /** Invites not accepted yet (owners and admins). */
+ invites: InviteView[];
+ /** How many SSH keys an account published (verified). */
+ published: (account: string) => number;
admin: boolean;
owner: boolean;
/** The organization asks members to publish their SSH keys. */
keysRequired: boolean;
+ /** The team has no usable plan: confirming people and changing roles wait for one. */
+ locked: boolean;
+ confirming: string | null;
+ setConfirming: (id: string | null) => void;
onChanged: () => void;
onGone: () => void;
/** Removing a confirmed member goes through offboarding. */
onOffboard: (m: MemberView) => void;
- /** The team has no usable plan: confirming people and changing roles wait for one. */
- locked: boolean;
}
-function Members({ org, members, memberKeys, admin, owner, keysRequired, onChanged, onGone, onOffboard, locked }: MembersProps) {
- const published = (account: string) => memberKeys.filter((k) => k.accountId === account && k.verified).length;
- const [confirming, setConfirming] = useState(null);
+/** Members, and the invites not accepted yet, in one table. */
+function MembersTab({ org, members, invites, published, keysRequired, admin, owner, locked, confirming, setConfirming, onChanged, onGone, onOffboard }: MembersProps) {
const [matches, setMatches] = useState(false);
const [removing, setRemoving] = useState(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
+ useEffect(() => setMatches(false), [confirming]);
const act = (p: Promise, then: () => void = onChanged) => {
setBusy(true);
@@ -693,166 +779,280 @@ function Members({ org, members, memberKeys, admin, owner, keysRequired, onChang
(e) => (setError(errorText(e)), setBusy(false)),
);
};
+ if (members === null) return error ? {error}
: Loading…
;
return (
-
-
Members
- {members === null && !error &&
Loading…
}
- {members && (
-
- {members.map((m) => {
- const canRemove = !m.me && m.role !== "owner" && admin && (m.role === "member" || owner);
- return (
-
-
-
- {(m.email[0] ?? "?").toUpperCase()}
-
-
- {m.email}
- {m.me && you }
- {ROLE_NAMES[m.role]}
-
- {m.status === "accepted" ? "Waiting for confirmation" : m.verified ? "Keys verified" : "Keys not verified yet"}
- {m.status === "confirmed" && ` · ${published(m.accountId)} SSH key${published(m.accountId) === 1 ? "" : "s"} published`}
- {m.status === "confirmed" && keysRequired && published(m.accountId) === 0 && (
- · none yet, and the organization asks for them
- )}
- · {m.fingerprint}
-
-
- {admin && m.status === "confirmed" && !m.verified && !m.me && (
- (setConfirming(confirming === m.accountId ? null : m.accountId), setMatches(false))}
- title="The admin who confirmed them left: compare their fingerprint and certify them again"
+ <>
+
+ {members.map((m, i) => {
+ const canRemove = !m.me && m.role !== "owner" && admin && (m.role === "member" || owner);
+ const n = published(m.accountId);
+ return (
+
+
+ {admin && m.status === "accepted" && (
+ setConfirming(confirming === m.accountId ? null : m.accountId)}>Confirm…
+ )}
+ {admin && m.status === "confirmed" && !m.verified && !m.me && (
+ setConfirming(confirming === m.accountId ? null : m.accountId)}>Verify…
+ )}
+ {canRemove && m.status === "confirmed" && (
+ onOffboard(m)}>
+ Remove…
+
+ )}
+ {((canRemove && m.status === "accepted") || (m.me && m.role !== "owner")) &&
+ (removing === m.accountId ? (
+ act(teams.remove(org.id, m.accountId), m.me ? onGone : onChanged)}
+ >
+ {m.me ? "Really leave?" : "Really remove?"}
+
+ ) : (
+ setRemoving(m.accountId)}>
+ {m.me ? "Leave" : "Remove"}
+
+ ))}
+ >
+ }
+ >
+ you} sub={m.fingerprint} subTone="mono" />
+
+ {owner && !m.me && m.status === "confirmed" && m.role !== "owner" ? (
+ act(teams.setRole(org.id, m.accountId, e.target.value as Role))}
>
- Verify…
-
+ Member
+ Admin
+
+ ) : (
+ ROLE_NAMES[m.role]
)}
- {admin && m.status === "accepted" && (
- (setConfirming(confirming === m.accountId ? null : m.accountId), setMatches(false))}
- >
- Confirm…
-
+ |
+
+ {m.status === "accepted" ? (
+ To confirm
+ ) : m.verified ? (
+ Verified
+ ) : (
+ Not verified
+ )}
+ |
+
+ {m.status !== "confirmed" ? (
+ –
+ ) : n > 0 ? (
+ `${n} published`
+ ) : keysRequired ? (
+ None yet
+ ) : (
+ None
)}
- {owner && !m.me && m.status === "confirmed" && m.role !== "owner" && (
+ |
+
+ {confirming === m.accountId && (
+
+
+ Ask {m.email} to read you their fingerprint (Teams, on their device), on a call or in person. Confirm only if it
+ matches:
+
+
+ {m.status === "accepted" && m.pendingVaults.length > 0 &&
Confirming gives them the vault access their invite offered.
}
+
+ setMatches(e.target.checked)} />
+ It matches what they read to me
+
+
+ setConfirming(null)}>
+ Cancel
+
act(teams.setRole(org.id, m.accountId, m.role === "admin" ? "member" : "admin"))}
- title={m.role === "admin" ? "They keep only the vault access you give them" : "Admins manage every vault and member"}
+ className="primary"
+ disabled={!matches || busy || (m.status === "accepted" && locked)}
+ onClick={() =>
+ act(
+ m.status === "confirmed"
+ ? teams.recertify(org.id, m.accountId, m.fingerprint)
+ : teams.confirm(org.id, m.accountId, m.fingerprint),
+ )
+ }
>
- {m.role === "admin" ? "Make member" : "Make admin"}
+ {m.status === "confirmed" ? `Certify ${m.email} again` : `Confirm ${m.email}`}
- )}
- {canRemove && m.status === "confirmed" && (
- onOffboard(m)}>
- Remove…
+
+
+ )}
+
+ );
+ })}
+ {invites.map((inv, i) => (
+ (setError(""), teams.cancelInvite(org.id, inv.id).then(onChanged, (e) => setError(errorText(e))))}
+ >
+ Cancel invite
+
+ }
+ >
+
+ {ROLE_NAMES[inv.role]} |
+
+ Invited
+ |
+
+ –
+ |
+
+ ))}
+
+ {error && {error}
}
+ >
+ );
+}
+
+/** The team's devices that asked to open its vaults, and the ones denied (approving is also in
+ * Needs you). */
+function DeviceRequests({
+ org,
+ requests,
+ admin,
+ required,
+ locked,
+ onChanged,
+}: {
+ org: OrgInfo;
+ requests: DeviceRequest[];
+ admin: boolean;
+ required: boolean;
+ locked: boolean;
+ onChanged: () => void;
+}) {
+ const [busy, setBusy] = useState(null);
+ const [error, setError] = useState("");
+ if (requests.length === 0) {
+ return required ? (
+
+ New devices wait for approval before they open the team's vaults. None is waiting.
+ {!admin && " When you sign in on a new device, approve it here, from this one."}
+
+ ) : null;
+ }
+ const decide = (r: DeviceRequest, approve: boolean) => {
+ setBusy(r.deviceId);
+ setError("");
+ teams.decideDevice(org.id, r.accountId, r.deviceId, approve).then(
+ () => (setBusy(null), onChanged()),
+ (e) => (setError(errorText(e)), setBusy(null)),
+ );
+ };
+ return (
+
+ Devices waiting
+
+ {requests.map((r, i) => (
+ Approve it from another of your devices
+ ) : (
+ <>
+ decide(r, true)}>Approve
+ {r.status === "pending" && (
+ decide(r, false)}>
+ Deny
)}
- {((canRemove && m.status === "accepted") || (m.me && m.role !== "owner")) &&
- (removing === m.accountId ? (
- act(teams.remove(org.id, m.accountId), m.me ? onGone : onChanged)}
- >
- {m.me ? "Really leave?" : "Really remove?"}
-
- ) : (
- setRemoving(m.accountId)}>
- {m.me ? "Leave" : "Remove"}
-
- ))}
-
- {confirming === m.accountId && (
-
-
-
- Ask {m.email} to read you their fingerprint (Teams, on their device), on a call or in person. Confirm only
- if it matches:
-
-
- {m.pendingVaults.length > 0 && (
-
Confirming gives them the vault access their invite offered.
- )}
-
- setMatches(e.target.checked)} />
- It matches what they read to me
-
-
- act(
- m.status === "confirmed"
- ? teams.recertify(org.id, m.accountId, m.fingerprint)
- : teams.confirm(org.id, m.accountId, m.fingerprint),
- )
- }
- >
- {m.status === "confirmed" ? `Certify ${m.email} again` : `Confirm ${m.email}`}
-
-
-
- )}
-
- );
- })}
-
- )}
+ >
+ )
+ }
+ >
+
+ {r.thisDevice ? "This device" : r.mine ? "Your device" : (r.email ?? "A member")} |
+ {r.status === "denied" ? Denied : Waiting } |
+
+ ))}
+
{error && {error}
}
-
+
);
}
-interface InvitesProps {
+const domainOf = (email: string) => email.trim().split("@").pop()?.toLowerCase() ?? "";
+
+/** Invite people: several addresses at once, a role, and the vaults they get once confirmed. */
+function InviteDialog({
+ org,
+ owner,
+ vaults,
+ domains,
+ onClose,
+ onChanged,
+}: {
org: OrgInfo;
owner: boolean;
vaults: VaultEntry[];
- invites: InviteView[];
/** The organization only invites addresses at these domains (empty = any). */
domains: string[];
+ onClose: () => void;
onChanged: () => void;
-}
-
-const domainOf = (email: string) => email.trim().split("@").pop()?.toLowerCase() ?? "";
-
-function Invites({ org, owner, vaults, invites, domains, onChanged }: InvitesProps) {
- const [email, setEmail] = useState("");
+}) {
+ const ref = useRef(null);
+ useEffect(() => {
+ ref.current?.showModal();
+ }, []);
+ const [text, setText] = useState("");
const [role, setRole] = useState("member");
const [grants, setGrants] = useState>({});
- const [created, setCreated] = useState(null);
- const [copied, setCopied] = useState(false);
+ const [sent, setSent] = useState(null);
+ const [copied, setCopied] = useState("");
+ const [copyFailed, setCopyFailed] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
- const wellFormed = /^[^@\s]+@[^@\s]+$/.test(email.trim());
- const domainOk = domains.length === 0 || domains.includes(domainOf(email));
- const valid = wellFormed && domainOk;
- const submit = (e: FormEvent) => {
+ const emails = [...new Set(text.split(/[,;\s]+/).map((e) => e.trim()).filter(Boolean))];
+ const malformed = emails.filter((e) => !/^[^@\s]+@[^@\s]+$/.test(e));
+ const outside = domains.length ? emails.filter((e) => !malformed.includes(e) && !domains.includes(domainOf(e))) : [];
+ const valid = emails.length > 0 && malformed.length === 0 && outside.length === 0;
+ const send = async (e: FormEvent) => {
e.preventDefault();
if (!valid) return;
setBusy(true);
setError("");
const vaultGrants = role === "admin" ? [] : Object.entries(grants).map(([vaultId, access]) => ({ vaultId, access }));
- teams.invite(org.id, email.trim(), role, vaultGrants).then(
- (inv) => (setCreated(inv), setCopied(false), setEmail(""), setGrants({}), setBusy(false), onChanged()),
- (err) => (setError(errorText(err)), setBusy(false)),
- );
- };
- const cancel = (id: string) => {
- setError("");
- teams.cancelInvite(org.id, id).then(onChanged, (e) => setError(errorText(e)));
+ const done: InviteView[] = [];
+ const failed: string[] = [];
+ for (const email of emails) {
+ try {
+ done.push(await teams.invite(org.id, email, role, vaultGrants));
+ } catch (err) {
+ failed.push(`${email}: ${errorText(err)}`);
+ }
+ }
+ setBusy(false);
+ if (done.length) {
+ setSent(done);
+ onChanged();
+ }
+ if (failed.length) setError(failed.join(" "));
};
const toggle = (id: string, on: boolean) =>
setGrants((g) => {
@@ -863,203 +1063,613 @@ function Invites({ org, owner, vaults, invites, domains, onChanged }: InvitesPro
});
return (
-
-
Invites
- {invites.length > 0 && (
-
- )}
- {created?.token && (
-
-
- This server doesn't email invites. Send this code to {created.email} (it works once, for that address, for 7 days):
-
-
{created.token}
-
-
void navigator.clipboard?.writeText(created.token ?? "").then(() => setCopied(true))}
+ (e.preventDefault(), onClose())} aria-labelledby="invite-title">
+ Invite people to {org.name}
+ {sent ? (
+
+ {sent.some((i) => i.token) ? (
+ <>
+
This server doesn't email invites. Send each person their code (it works once, for that address, for 7 days):
+ {sent.map((i) => (
+
+
{i.email}
+
+ {i.token}
+ void copyText(i.token ?? "").then((ok) => (ok ? setCopied(i.id) : setCopyFailed(i.id)))}
+ >
+ {copied === i.id ? "Copied" : copyFailed === i.id ? "Couldn't copy" : "Copy code"}
+
+
+
+ ))}
+ >
+ ) : (
+
Invite emailed to {sent.map((i) => i.email).join(", ")}.
+ )}
+
When they join, you compare each person's fingerprint with them before confirming them.
+ {error &&
{error}
}
+
+
+
+ Done
+
+
+
+ ) : (
+ void send(e)}>
+
+ Email addresses
+ setText(e.target.value)}
+ placeholder="colleague@example.com, another@example.com"
+ aria-label="Emails to invite"
+ autoCapitalize="off"
+ spellCheck={false}
+ autoFocus
+ />
+
+
+ {malformed.length
+ ? `Not an email address: ${malformed.join(", ")}.`
+ : outside.length
+ ? `Invites go only to ${domains.map((d) => `@${d}`).join(", ")}: not ${outside.join(", ")}.`
+ : `MorphGate's server emails each one an invite that works once, for that address, for 7 days${domains.length ? `. Only addresses at ${domains.map((d) => `@${d}`).join(", ")}` : ""}.`}
+
+ {owner && (
+
+ Role
+ setRole(e.target.value as Role)} aria-label="Role">
+ Member: the vaults you give them
+ Admin: every vault and member
+
+
+ )}
+ {role === "member" && vaults.length > 0 && (
+
+
Vaults they get once you confirm them
+ {vaults.map((v) => (
+
+ ))}
+
+ )}
+ {error && {error}
}
+
+
+
+ Cancel
+
+
+ {busy ? "Sending…" : emails.length > 1 ? `Send ${emails.length} invites` : "Send invite"}
+
+
+
+ )}
+
+ );
+}
+
+/** One setting: what it is and does, and its control. */
+function SettingRow({ name, desc, children }: { name: ReactNode; desc: ReactNode; children: ReactNode }) {
+ return (
+
+
+ {name}
+ {desc}
+
+ {children}
+
+ );
+}
+
+function Switch({ on, label, disabled, onChange }: { on: boolean; label: string; disabled?: boolean; onChange: (on: boolean) => void }) {
+ return (
+ onChange(!on)}>
+
+
+ );
+}
+
+/** The team's policies, grouped, saved together. */
+function Settings({ org, details, onChanged }: { org: OrgInfo; details: OrgDetails; onChanged: () => void }) {
+ const [p, setP] = useState(details.policies);
+ const [domains, setDomains] = useState(details.policies.allowedEmailDomains.join(", "));
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState("");
+ const list = domains
+ .split(/[,\s]+/)
+ .map((d) => d.trim().replace(/^@/, "").toLowerCase())
+ .filter(Boolean);
+ const next: Policies = { ...p, allowedEmailDomains: list };
+ const approvalOffered = details.features === null || details.features.includes("device_approval");
+ const dirty = JSON.stringify(next) !== JSON.stringify(details.policies);
+ const save = () => {
+ setBusy(true);
+ setError("");
+ teams.setPolicies(org.id, next).then(
+ () => (setBusy(false), onChanged()),
+ (e) => (setError(errorText(e)), setBusy(false)),
+ );
+ };
+ const discard = () => (setP(details.policies), setDomains(details.policies.allowedEmailDomains.join(", ")));
+ return (
+
+
Security
+
+
+ setP({ ...p, requireTotp: v })} />
+
+
+ Approve new devices
+ {!approvalOffered && Business plan }
+ >
+ }
+ desc="An owner or admin, or the member from a device already approved, approves each new device before it opens the team's vaults. Devices in use now stay approved."
+ >
+ setP({ ...p, requireDeviceApproval: v })}
+ />
+
+ {p.requireSso && (
+
+ Required
+
+ )}
+
+
Members
+
+
+ setP({ ...p, membersCanCreateVaults: v })} />
+
+
+ setDomains(e.target.value)}
+ placeholder="example.com"
+ aria-label="Invite only these domains"
+ spellCheck={false}
+ autoCapitalize="off"
+ />
+
+
+ setP({ ...p, requireMemberKeys: v })} />
+
+
+ {details.sessionLogsOffered && (
+ <>
+
Session logs
+
+
+
+ setP({ ...p, sessionLogRetentionDays: Math.max(0, Math.min(3650, Math.floor(Number(e.target.value) || 0))) })}
+ />
+ days
+
+
+
+ >
+ )}
+ {dirty && (
+
+ Not saved yet.
+
+ Discard
+
+
+ Save settings
+
+
+ )}
+ {error &&
{error}
}
+
+ );
+}
+
+/** Ownership: handing the team to an admin, or deleting it (the master password confirms). */
+function OwnerZone({ org, members, onChanged, onGone }: { org: OrgInfo; members: MemberView[]; onChanged: () => void; onGone: () => void }) {
+ const [mode, setMode] = useState<"transfer" | "delete" | null>(null);
+ const [to, setTo] = useState("");
+ const [password, setPassword] = useState("");
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState("");
+ const admins = members.filter((m) => !m.me && m.role === "admin" && m.status === "confirmed");
+
+ const submit = (e: FormEvent) => {
+ e.preventDefault();
+ setBusy(true);
+ setError("");
+ const p = mode === "transfer" ? teams.transfer(org.id, to, password) : teams.deleteOrg(org.id, password);
+ p.then(
+ () => (setPassword(""), setBusy(false), setMode(null), mode === "delete" ? onGone() : onChanged()),
+ (err) => (setError(errorText(err)), setBusy(false)),
+ );
+ };
+
+ return (
+
+
Ownership
+
+
+ setMode("transfer")}>
+ Transfer…
+
+
+
+ setMode("delete")}>
+ Delete team…
+
+
+
+ {mode && (
+
+ {mode === "transfer" ? (
+ <>
+ The new owner gets every right you have; you stay on as an admin.
+ setTo(e.target.value)} aria-label="New owner">
+ Choose an admin…
+ {admins.map((m) => (
+
+ {m.email}
+
+ ))}
+
+ >
+ ) : (
+
+ Deletes {org.name} and all its vaults for everyone. Items in them disappear from every member's devices on their next sync.
+ This can't be undone.
+
+ )}
+
+ Your master password
+ setPassword(e.target.value)} autoComplete="current-password" />
+
+
+ (setMode(null), setPassword(""), setError(""))}>
+ Cancel
+
+
- {copied ? "Copied" : "Copy code"}
+ {mode === "transfer" ? "Transfer" : `Delete ${org.name}`}
- setCreated(null)}>
- Done
+
+
+ )}
+ {error &&
{error}
}
+
+ );
+}
+
+const PAGE = 50;
+
+/** The audit log: what the server saw (owners and admins). */
+function ActivityLog({ org }: { org: OrgInfo }) {
+ const [log, setLog] = useState(null);
+ const [shown, setShown] = useState(PAGE);
+ const [error, setError] = useState("");
+ const [saved, setSaved] = useState("");
+ useEffect(() => {
+ teams.activity(org.id).then(setLog, (e) => setError(errorText(e)));
+ }, [org.id]);
+ const download = (format: "csv" | "jsonl") => {
+ setError("");
+ setSaved("");
+ teams.exportActivity(org.id, format).then(setSaved, (e) => setError(errorText(e)));
+ };
+ return (
+
+ {!log && !error &&
Loading…
}
+ {log && (
+ <>
+
+
+ The server records what it sees: membership, access, keys and policy changes. It can't see which hosts anyone opened or
+ what they ran.
+
+
download("csv")}>
+ Download CSV
+
+
download("jsonl")}>
+ Download JSON lines
+
+
+ {saved &&
Saved {saved}
}
+
+ {log.events.slice(0, shown).map((e) => (
+
+ {dateTimeText(e.at)}
+ {describe(e, log.names)}
+
+ ))}
+
+ {shown < log.events.length && (
+
setShown(shown + PAGE)}>
+ Show older
+
+ )}
+ {shown >= log.events.length && log.truncated &&
Older events aren't shown in the app.
}
+ >
+ )}
+ {error &&
{error}
}
+
+ );
+}
+
+/** A fingerprint in groups of four, easy to read out loud. */
+function Fingerprint({ value }: { value: string }) {
+ return (
+
+ {value.split("-").map((g, i) => (
+ {g}
+ ))}
+
+ );
+}
+
+/** `invite`: from a link, looked up at once (joining still waits for the fingerprint check). */
+function JoinOrg({ onJoined, invite }: { onJoined: (id: string) => void; invite?: string }) {
+ const [token, setToken] = useState(invite ?? "");
+ const [preview, setPreview] = useState(null);
+ const [matches, setMatches] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState("");
+
+ const look = (code: string) => {
+ if (!code.trim()) return;
+ setBusy(true);
+ setError("");
+ teams.preview(code.trim()).then(
+ (p) => (setPreview(p), setMatches(false), setBusy(false)),
+ (err) => (setError(errorText(err)), setBusy(false)),
+ );
+ };
+ const lookUp = (e: FormEvent) => {
+ e.preventDefault();
+ look(token);
+ };
+ useEffect(() => {
+ if (invite) look(invite);
+ }, [invite]);
+ const join = () => {
+ if (!preview) return;
+ setBusy(true);
+ setError("");
+ teams.accept(token.trim(), preview.ownerFingerprint).then(
+ (id) => (setToken(""), setPreview(null), setBusy(false), onJoined(id)),
+ (err) => (setError(errorText(err)), setBusy(false)),
+ );
+ };
+
+ return (
+
+ Join with an invite
+ {!preview ? (
+
+ setToken(e.target.value)}
+ placeholder="Invite code"
+ aria-label="Invite code"
+ spellCheck={false}
+ autoCapitalize="off"
+ />
+
+ Look up
+
+
+ ) : (
+
+
+ {preview.orgName} invites {preview.email} as {ROLE_NAMES[preview.role].toLowerCase()} (sent by{" "}
+ {preview.inviterEmail}).
+
+
+ Ask the owner (or whoever invited you: it's in their member list) to read you the owner's fingerprint. Join only if
+ it matches this one:
+
+
+
+ setMatches(e.target.checked)} />
+ It matches the fingerprint they read to me
+
+
+ setPreview(null)}>
+ Cancel
+
+
+ Join {preview.orgName}
- )}
- {created && !created.token && Invite emailed to {created.email}.
}
-
+ )}
+ {error && {error}
}
+
+ );
+}
+
+function Leave({ org, me, onGone }: { org: OrgInfo; me: string; onGone: () => void }) {
+ const [sure, setSure] = useState(false);
+ const [error, setError] = useState("");
+ return (
+ <>
+ (sure ? teams.remove(org.id, me).then(onGone, (e) => setError(errorText(e))) : setSure(true))}
+ >
+ {sure ? `Really leave ${org.name}?` : "Leave"}
+
+ {error && {error}
}
+ >
+ );
+}
+
+function AccessSelect({ value, onChange, label, disabled }: { value: Access; onChange: (a: Access) => void; label: string; disabled?: boolean }) {
+ return (
+ onChange(e.target.value as Access)} aria-label={label} disabled={disabled}>
+ {ACCESS_LEVELS.map((a) => (
+
+ {ACCESS_NAMES[a]}
+
+ ))}
+
+ );
+}
+
+function AccessEditor({ vault, members, locked, onChanged }: { vault: VaultEntry; members: MemberView[]; locked: boolean; onChanged: () => void }) {
+ const [list, setList] = useState(null);
+ const [add, setAdd] = useState("");
+ const [addAccess, setAddAccess] = useState("write");
+ const [revoked, setRevoked] = useState(false);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState("");
+
+ const load = useCallback(() => {
+ teams.access(vault.id).then(setList, (e) => setError(errorText(e)));
+ }, [vault.id]);
+ useEffect(load, [load]);
+
+ const act = (p: Promise, after?: () => void) => {
+ setBusy(true);
+ setError("");
+ p.then(
+ () => (after?.(), setBusy(false), load(), onChanged()),
+ (e) => (setError(errorText(e)), setBusy(false)),
+ );
+ };
+ const candidates = members.filter(
+ (m) => m.status === "confirmed" && m.role === "member" && !list?.some((a) => a.accountId === m.accountId),
+ );
+
+ return (
+
+ {list === null && !error &&
Loading…
}
+ {list?.map((a) => (
+
+
{a.email || a.accountId}
+ {a.implicit ? (
+
{ACCESS_NAMES.manage} (owner or admin)
+ ) : (
+ <>
+
act(teams.setAccess(vault.id, a.accountId, x))} />
+ act(teams.revokeAccess(vault.id, a.accountId), () => setRevoked(true))}
+ >
+ Remove
+
+ >
+ )}
+
+ ))}
+ {candidates.length > 0 && (
-
setEmail(e.target.value)}
- placeholder="colleague@example.com"
- aria-label="Email to invite"
- autoCapitalize="off"
- spellCheck={false}
- />
- {owner && (
-
setRole(e.target.value as Role)} aria-label="Role">
- Member
- Admin
-
- )}
-
- Invite
+ setAdd(e.target.value)} aria-label="Member to give access" disabled={locked}>
+ Give a member access…
+ {candidates.map((m) => (
+
+ {m.email}
+
+ ))}
+
+
+ act(teams.setAccess(vault.id, add, addAccess), () => setAdd(""))}
+ >
+ Add
- {role === "member" && vaults.length > 0 && (
-
-
Vaults they get once you confirm them:
- {vaults.map((v) => (
-
- ))}
-
- )}
- {role === "admin" &&
Admins open every vault of the organization and manage members.
}
- {domains.length > 0 && (
-
Invites go only to {domains.map((d) => `@${d}`).join(", ")}.
- )}
-
+ )}
+ {revoked && (
+
+ They can't sync this vault anymore, but what they already had stays on their devices. Rotate the key (the button
+ appears after the next sync) so they can't read anything added from now on, and change the passwords and keys they
+ could see.
+
+ )}
+ {members.some((m) => m.status === "accepted") &&
Members waiting for confirmation can be added once confirmed.
}
{error &&
{error}
}
);
}
-function OwnerZone({ org, members, onChanged, onGone }: { org: OrgInfo; members: MemberView[]; onChanged: () => void; onGone: () => void }) {
- const [mode, setMode] = useState<"transfer" | "delete" | null>(null);
- const [to, setTo] = useState("");
- const [password, setPassword] = useState("");
+/** New vault, as a bar (like New team): its name, Create vault or Cancel. */
+function NewVault({ orgId, onCreated, onCancel }: { orgId: string; onCreated: () => void; onCancel: () => void }) {
+ const [name, setName] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
- const admins = members.filter((m) => !m.me && m.role === "admin" && m.status === "confirmed");
-
const submit = (e: FormEvent) => {
e.preventDefault();
+ if (!name.trim()) return;
setBusy(true);
setError("");
- const p = mode === "transfer" ? teams.transfer(org.id, to, password) : teams.deleteOrg(org.id, password);
- p.then(
- () => (setPassword(""), setBusy(false), setMode(null), mode === "delete" ? onGone() : onChanged()),
+ teams.createVault(orgId, name.trim()).then(
+ () => (setName(""), setBusy(false), onCreated()),
(err) => (setError(errorText(err)), setBusy(false)),
);
};
-
return (
-
-
Ownership
- {!mode && (
-
- setMode("transfer")}>
- Transfer ownership
-
- setMode("delete")}>
- Delete organization
-
-
- )}
- {!mode && admins.length === 0 &&
To hand the organization over, make someone an admin first.
}
- {mode && (
-
- {mode === "transfer" ? (
- <>
- The new owner gets every right you have; you stay on as an admin.
- setTo(e.target.value)} aria-label="New owner">
- Choose an admin…
- {admins.map((m) => (
-
- {m.email}
-
- ))}
-
- >
- ) : (
-
- Deletes {org.name} and all its vaults for everyone. Items in them disappear from every member's devices on their next
- sync. This can't be undone.
-
- )}
-
- Your master password
- setPassword(e.target.value)} autoComplete="current-password" />
-
-
- (setMode(null), setPassword(""), setError(""))}>
- Cancel
-
-
- {mode === "transfer" ? "Transfer" : `Delete ${org.name}`}
-
-
-
- )}
- {error &&
{error}
}
-
+
+ New vault
+ setName(e.target.value)} placeholder="Staging" aria-label="Vault name" autoFocus />
+
+ Cancel
+
+
+ Create vault
+
+ Then give members access to it (Access, on its row).
+ {error && {error}
}
+
);
}
const day = (iso: string | null) => (iso ? dateText(iso) : "");
/** The organization's plan (hosted servers), and what it means for its vaults. */
-function PlanLine({
- org,
- plan,
- enforced,
- seatsUsed,
- portal,
-}: {
- org: OrgInfo;
- plan: OrgPlan | null;
- enforced: boolean;
- seatsUsed: number | null;
- /** The owner can open the team's billing page. */
- portal: boolean;
-}) {
- const [error, setError] = useState("");
- // The team's Billing tab in the account portal, signed in.
- const billing = () => sync.openPortal(`/account/team/${org.id}/billing`).catch((e) => setError(errorText(e)));
+function PlanLine({ plan, enforced, seatsUsed }: { plan: OrgPlan | null; enforced: boolean; seatsUsed: number | null }) {
if (!plan) {
- return (
-
-
- {enforced ? "No plan: shared vaults can be read but not changed until the team has a plan." : "No plan"}
-
- {portal && (
-
- Choose a plan ↗
-
- )}
- {error &&
{error}
}
-
- );
+ return {enforced ? "No plan: shared vaults can be read but not changed until the team has a plan." : "No plan"}
;
}
const name = plan.plan.charAt(0).toUpperCase() + plan.plan.slice(1);
const seats = plan.seats != null ? ` · ${seatsUsed ?? "?"} of ${plan.seats} seats used` : "";
@@ -1079,87 +1689,7 @@ function PlanLine({
return [`${name} plan${plan.expiresAt ? `, renews ${day(plan.expiresAt)}` : ""}${seats}`, "hint"];
}
})();
- return (
-
-
{text}
- {portal && (
-
- Manage plan ↗
-
- )}
- {error &&
{error}
}
-
- );
-}
-
-/** Devices waiting to open the organization's vaults, and the ones denied. */
-function DeviceRequests({ org, admin, required }: { org: OrgInfo; admin: boolean; required: boolean }) {
- const [requests, setRequests] = useState(null);
- const [busy, setBusy] = useState(null);
- const [error, setError] = useState("");
- const load = useCallback(() => {
- teams.deviceRequests(org.id).then(setRequests, (e) => setError(errorText(e)));
- }, [org.id]);
- useEffect(load, [load]);
- if (!requests || (requests.length === 0 && !error)) {
- return required ? (
-
-
Devices
-
- New devices wait for approval before they open the organization's vaults. None is waiting.
- {!admin && " When you sign in on a new device, approve it here, from this one."}
-
-
- ) : null;
- }
- const decide = (r: DeviceRequest, approve: boolean) => {
- setBusy(r.deviceId);
- setError("");
- teams.decideDevice(org.id, r.accountId, r.deviceId, approve).then(
- () => (setBusy(null), load()),
- (e) => (setError(errorText(e)), setBusy(null)),
- );
- };
- const pending = requests.filter((r) => r.status === "pending");
- return (
-
-
{pending.length > 0 ? "Waiting for approval" : "Devices"}
-
- {requests.map((r) => (
-
-
-
{r.name ?? "A device"}
-
- {[
- r.thisDevice ? "This device" : r.mine ? "Your device" : (r.email ?? "A member"),
- r.platform && platformName(r.platform),
- `asked ${dateTimeText(r.requestedAt)}`,
- ]
- .filter(Boolean)
- .join(" · ")}
-
-
- {r.status === "denied" ? "Denied" : "Waiting"}
- {r.thisDevice ? (
- Approve it from another of your devices
- ) : (
- <>
- decide(r, true)}>
- Approve
-
- {r.status === "pending" && (
- decide(r, false)}>
- Deny
-
- )}
- >
- )}
-
- ))}
-
- {error &&
{error}
}
-
- );
+ return {text}
;
}
/** The organization's identity provider, and whether this device is signed in there. */
@@ -1187,98 +1717,6 @@ function SsoLine({ org, onSignIn }: { org: OrgInfo; onSignIn: () => void }) {
);
}
-function PolicyEditor({ org, details, onChanged }: { org: OrgInfo; details: OrgDetails; onChanged: () => void }) {
- const [p, setP] = useState(details.policies);
- const [domains, setDomains] = useState(details.policies.allowedEmailDomains.join(", "));
- const [busy, setBusy] = useState(false);
- const [error, setError] = useState("");
- const list = domains
- .split(/[,\s]+/)
- .map((d) => d.trim().replace(/^@/, "").toLowerCase())
- .filter(Boolean);
- const next: Policies = { ...p, allowedEmailDomains: list };
- const approvalOffered = details.features === null || details.features.includes("device_approval");
- const dirty = JSON.stringify(next) !== JSON.stringify(details.policies);
- const save = () => {
- setBusy(true);
- setError("");
- teams.setPolicies(org.id, next).then(
- () => (setBusy(false), onChanged()),
- (e) => (setError(errorText(e)), setBusy(false)),
- );
- };
- return (
-
-
Policies
-
- setP({ ...p, requireTotp: e.target.checked })}
- />
-
- Require two-step verification to open the organization's vaults
- {!details.totpOffered && " (this server doesn't offer two-step verification)"}
-
-
-
- setP({ ...p, membersCanCreateVaults: e.target.checked })} />
- Members can create vaults (they manage the ones they create)
-
-
- Invite only addresses at these domains (empty: any)
- setDomains(e.target.value)} placeholder="example.com, example.org" spellCheck={false} autoCapitalize="off" />
-
-
- setP({ ...p, requireDeviceApproval: e.target.checked })}
- />
-
- Owners and admins approve new devices before they open the organization's vaults (members can approve their own, from a device
- already approved). Devices in use now stay approved.
- {!approvalOffered && " Needs the Business plan."}
-
-
-
- setP({ ...p, requireMemberKeys: e.target.checked })}
- />
-
- Members publish the SSH keys they use on the organization's servers (so leaving can remove them); the app reminds those who
- haven't
-
-
- {details.sessionLogsOffered && (
-
- Delete the team's session logs after this many days (0 keeps them)
- setP({ ...p, sessionLogRetentionDays: Math.max(0, Math.min(3650, Math.floor(Number(e.target.value) || 0))) })}
- />
-
- )}
- {p.requireSso && (
-
Single sign-on is required for the organization's vaults. It is set where the identity provider is, on the website.
- )}
- {dirty && (
-
- Save policies
-
- )}
- {error &&
{error}
}
-
- );
-}
-
/** One audit event as a sentence. */
function describe(e: Activity["events"][number], names: Record): string {
const who = (id: string | null) => (id ? (names[id] ?? "a former member") : "someone");
@@ -1289,9 +1727,9 @@ function describe(e: Activity["events"][number], names: Record):
const access = e.detail && e.detail in ACCESS_NAMES ? ` (${ACCESS_NAMES[e.detail as Access].toLowerCase()})` : "";
switch (e.kind) {
case "org_created":
- return `${actor} created the organization`;
+ return `${actor} created the team`;
case "org_updated":
- return `${actor} changed the organization's name or policies`;
+ return `${actor} changed the team's name or settings`;
case "member_invited":
return `${actor} invited ${e.detail ?? "someone"}`;
case "invite_cancelled":
@@ -1341,82 +1779,21 @@ function describe(e: Activity["events"][number], names: Record):
}
}
-const PAGE = 50;
-
-/** The audit log: what the server saw (owners and admins). */
-function ActivityLog({ org }: { org: OrgInfo }) {
- const [log, setLog] = useState(null);
- const [shown, setShown] = useState(PAGE);
- const [open, setOpen] = useState(false);
- const [error, setError] = useState("");
- const [saved, setSaved] = useState("");
- const load = () => {
- setOpen(true);
- setError("");
- teams.activity(org.id).then(setLog, (e) => setError(errorText(e)));
- };
- const download = (format: "csv" | "jsonl") => {
- setError("");
- setSaved("");
- teams.exportActivity(org.id, format).then(setSaved, (e) => setError(errorText(e)));
- };
- return (
-
-
Activity
- {!open && (
-
- Show activity
-
- )}
- {open && !log && !error &&
Loading…
}
- {log && (
- <>
-
- {log.events.slice(0, shown).map((e) => (
-
- {dateTimeText(e.at)}
- {describe(e, log.names)}
-
- ))}
-
- {shown < log.events.length && (
-
setShown(shown + PAGE)}>
- Show older
-
- )}
- {shown >= log.events.length && log.truncated &&
Older events aren't shown in the app.
}
-
- The server records what it sees: membership, access, keys and policy changes. It can't see which hosts anyone
- opened or what they ran.
-
-
- download("csv")}>
- Download CSV
-
- download("jsonl")}>
- Download JSON lines
-
-
- {saved &&
Saved {saved}
}
- >
- )}
- {error &&
{error}
}
-
- );
-}
-
/** This account's SSH keys published to the organization's member key directory. */
function MyKeys({
org,
keys,
published,
ready,
+ waiting,
onChanged,
}: {
org: OrgInfo;
keys: KeyView[];
published: MemberKeyView[];
ready: boolean;
+ /** The directory waits like the organization's vaults (two-step verification, said above). */
+ waiting: boolean;
onChanged: () => void;
}) {
const [pick, setPick] = useState("");
@@ -1438,7 +1815,7 @@ function MyKeys({
Publish the public keys you use on {org.name}'s servers. If you leave, admins can then find and remove your access
from those servers. Only the public half is shared.
- {!ready && The organization's member key directory arrives with the next sync.
}
+ {!ready && !waiting && The organization's member key directory arrives with the next sync.
}
{published.length > 0 && (
{published.map((k) => (
@@ -1654,7 +2031,7 @@ function Offboard({ org, member, onBack, onChanged }: { org: OrgInfo; member: Me
4. Change what they could see
- {summary.secrets.length === 0 &&
No saved passwords, SSH keys or VPN profiles in those vaults.
}
+ {summary.secrets.length === 0 &&
No saved passwords, SSH keys, VPN profiles or feed tokens in those vaults.
}
{summary.secrets.map((s) => {
const r = replaced[s.itemId];
@@ -1663,7 +2040,9 @@ function Offboard({ org, member, onBack, onChanged }: { org: OrgInfo; member: Me
tick(s.itemId)} />
- {s.kind === "password" ? "Password of" : s.kind === "key" ? "SSH key" : "VPN profile"} {s.label}
+ {s.kind === "password" ? "Password of" : s.kind === "key" ? "SSH key" : s.kind === "feed" ? "Rotate the token for" : "VPN profile"}{" "}
+ {s.label}
+ {s.kind === "feed" && " (a feed: change it where the feed is published, then in the source's settings)"}
{" "}
· {s.vaultName}
diff --git a/apps/desktop/src/Terms.tsx b/apps/desktop/src/Terms.tsx
new file mode 100644
index 0000000..edf2601
--- /dev/null
+++ b/apps/desktop/src/Terms.tsx
@@ -0,0 +1,27 @@
+import { openLink, type SyncTerms } from "./api";
+
+/** The server's terms of use and privacy policy, each opening in the browser. */
+export function TermsLinks({ terms }: { terms: SyncTerms }) {
+ return (
+ <>
+ void openLink(terms.termsUrl)}>
+ terms of use
+ {" "}
+ and the{" "}
+ void openLink(terms.privacyUrl)}>
+ privacy policy
+
+ >
+ );
+}
+
+export function TermsCheck({ terms, accepted, onChange }: { terms: SyncTerms; accepted: boolean; onChange: (v: boolean) => void }) {
+ return (
+
+ onChange(e.target.checked)} />
+
+ I accept the .
+
+
+ );
+}
diff --git a/apps/desktop/src/VaultGate.tsx b/apps/desktop/src/VaultGate.tsx
index 03b47ad..b04ad9a 100644
--- a/apps/desktop/src/VaultGate.tsx
+++ b/apps/desktop/src/VaultGate.tsx
@@ -1,34 +1,61 @@
-import { useEffect, useState, type FormEvent, type ReactNode } from "react";
-import { device, errorText, isCancelled, isTotpRequired, presenceName, sync, vault, type PasswordStrength, type PresenceKind } from "./api";
+import { listen } from "@tauri-apps/api/event";
+import { useEffect, useRef, useState, type FormEvent, type ReactNode } from "react";
+import {
+ device,
+ errorText,
+ isCancelled,
+ isTotpRequired,
+ links,
+ presenceName,
+ sync,
+ teams,
+ vault,
+ type InvitePreview,
+ type PasswordStrength,
+ type PresenceKind,
+ type SyncServer,
+} from "./api";
import { HOSTED_SERVER, SyncServerField } from "./SyncServerField";
+import { TermsCheck } from "./Terms";
import { isTotpCode, TotpCode } from "./TotpCode";
import appIcon from "./assets/brand/app-icon.svg";
import { MobileUnlock } from "./mobile/MobileUnlock";
import { onPhone, useMobile } from "./mobile/useMobile";
+import { copyText } from "./clipboard";
const MIN_CHARS = 10;
-/** The app icon over a card; `footnote` explains the screen's circumstances under it. */
-/** First run: start a new vault, or sign in to the one already synced from another device. */
-function StartChoice({ signIn, onChange }: { signIn: boolean; onChange: (signIn: boolean) => void }) {
+/** How the first screen starts: a vault on this device only, a vault with an account that syncs
+ * it, or signing in to the account already used on another device. */
+type Start = "vault" | "account" | "signIn";
+
+const STARTS: [Start, string][] = [
+ ["vault", "New vault"],
+ ["account", "New account"],
+ ["signIn", "Sign in"],
+];
+
+function StartChoice({ value, onChange }: { value: Start; onChange: (s: Start) => void }) {
return (
-
-
onChange(false)}>
- New vault
-
-
onChange(true)}>
- I have an account
-
+
+ {STARTS.map(([s, label]) => (
+ onChange(s)}>
+ {label}
+
+ ))}
);
}
+/** The app icon over a card; `footnote` explains the screen's circumstances under it. */
function Card({
title,
lead,
footnote,
choice,
notice,
+ invite,
+ after,
children,
}: {
title: string;
@@ -37,6 +64,10 @@ function Card({
choice?: ReactNode;
/** Above everything: why the app is back at the start (the device was erased). */
notice?: ReactNode;
+ /** An invite waiting for an account (`InviteNote`). */
+ invite?: ReactNode;
+ /** Under the form. */
+ after?: ReactNode;
children: ReactNode;
}) {
return (
@@ -44,18 +75,112 @@ function Card({
{notice &&
{notice}
}
+ {invite}
{choice}
{children}
+ {after}
{footnote &&
{footnote}
}
);
}
+/** An invite to a team that waits for an account: from a link opened before there was a vault,
+ * or pasted on this screen. It stays waiting until the workspace opens Teams with it. */
+interface PendingInvite {
+ token: string;
+ /** The server the link names; none for a bare code (MorphGate's is asked). */
+ server: string | null;
+ preview: InvitePreview | null;
+ /** Why it couldn't be looked up (only said when the link named its server). */
+ error: string;
+}
+
+function usePendingInvite(): PendingInvite | null {
+ const [invite, setInvite] = useState(null);
+ useEffect(() => {
+ let live = true;
+ const look = () =>
+ void links.peek().then((all) => {
+ const found = [...all].reverse().find((r) => r.link?.kind === "invite")?.link;
+ if (!live || found?.kind !== "invite") return;
+ const { token, server } = found;
+ setInvite((cur) => (cur?.token === token ? cur : { token, server, preview: null, error: "" }));
+ const same = (cur: PendingInvite | null) => cur?.token === token;
+ teams.peek(server ?? HOSTED_SERVER, token).then(
+ (preview) => live && setInvite((cur) => (same(cur) ? { ...cur!, preview } : cur)),
+ (e) => live && server && setInvite((cur) => (same(cur) ? { ...cur!, error: errorText(e) } : cur)),
+ );
+ }, () => {});
+ look();
+ const unlisten = listen("links-pending", look);
+ return () => {
+ live = false;
+ void unlisten.then((f) => f());
+ };
+ }, []);
+ return invite;
+}
+
+function InviteNote({ invite }: { invite: PendingInvite }) {
+ const p = invite.preview;
+ if (invite.error) return This invite can't be opened: {invite.error}
;
+ return (
+
+ {p ? (
+ <>
+ {p.inviterEmail || "An admin"} invited {p.email} to join {p.orgName} as {p.role === "admin" ? "an admin" : "a member"}.
+ >
+ ) : (
+ "You have an invite to a team."
+ )}{" "}
+ Create your account{p ? " with that address" : ""}, or sign in if you have one: Teams then opens with the invite.
+
+ );
+}
+
+/** "Joining a team?": the invite's code, or its link, pasted in. */
+function PasteInvite() {
+ const [open, setOpen] = useState(false);
+ const [text, setText] = useState("");
+ const [error, setError] = useState("");
+ if (!open) {
+ return (
+ setOpen(true)}>
+ Joining a team? Paste your invite
+
+ );
+ }
+ return (
+ {
+ e.preventDefault();
+ setError("");
+ links.addInvite(text).catch((err) => setError(errorText(err)));
+ }}
+ >
+
+ Invite code or link
+ setText(e.target.value)} autoFocus spellCheck={false} autoComplete="off" />
+
+ {error && {error}
}
+
+ (setOpen(false), setError(""))}>
+ Cancel
+
+
+ Use this invite
+
+
+
+ );
+}
+
const PresenceIcon = () => (
@@ -107,11 +232,28 @@ export function NewPasswordFields({
strength: PasswordStrength | null;
}) {
const s = meter(password, strength);
+ // A suggestion is shown, in the clear, while both fields still hold it.
+ const [suggested, setSuggested] = useState("");
+ const [error, setError] = useState("");
+ const suggest = () => {
+ setError("");
+ vault.suggest().then(
+ (p) => (setSuggested(p), setPassword(p), setConfirm(p)),
+ (e) => setError(errorText(e)),
+ );
+ };
+ const showing = suggested !== "" && password === suggested && confirm === suggested;
return (
<>
{label}
- setPassword(e.target.value)} autoComplete="new-password" />
+ setPassword(e.target.value)}
+ autoComplete="new-password"
+ />
@@ -119,6 +261,22 @@ export function NewPasswordFields({
{password ? s.label : `At least ${MIN_CHARS} characters, not easy to guess: a few unrelated words make a strong one.`}
+ {showing ? (
+
+
+ {suggested}
+
+ Five random words. Write them down, or save them in a password manager: you'll need them to unlock MorphGate.
+
+ Suggest another
+
+
+ ) : (
+
+ Suggest a strong password
+
+ )}
+ {error && {error}
}
Repeat password
setConfirm(e.target.value)} autoComplete="new-password" />
@@ -135,6 +293,7 @@ const CLIPBOARD_SECONDS = 60;
export function RecoveryCode({ code, action, onDone }: { code: string; action: string; onDone: () => void }) {
const [saved, setSaved] = useState(false);
const [copied, setCopied] = useState(false);
+ const [copyFailed, setCopyFailed] = useState(false);
// Copied: cleared on leaving this screen (a click, so it works everywhere), or after a minute.
const clear = () => {
if (copied) void navigator.clipboard?.writeText("").catch(() => {});
@@ -151,8 +310,8 @@ export function RecoveryCode({ code, action, onDone }: { code: string; action: s
{group}
))}
- void navigator.clipboard?.writeText(code).then(() => setCopied(true))}>
- {copied ? "Copied" : "Copy to clipboard"}
+ void copyText(code).then((ok) => (ok ? setCopied(true) : setCopyFailed(true)))}>
+ {copied ? "Copied" : copyFailed ? "Couldn't copy: write it down" : "Copy to clipboard"}
{copied && MorphGate clears the clipboard when you continue, so the key doesn't stay there.
}
@@ -166,10 +325,31 @@ export function RecoveryCode({ code, action, onDone }: { code: string; action: s
);
}
-function SignIn({ onDone, onBack, notice }: { onDone: () => void; onBack: () => void; notice?: ReactNode }) {
+/** What the first screen's forms share: the choice, its notes, and what an invite fills in. */
+interface StartProps {
+ choice: ReactNode;
+ notice?: ReactNode;
+ invite: PendingInvite | null;
+ after?: ReactNode;
+}
+
+/** The server and address an invite names, once known (unless already typed in). */
+function useInviteFill(invite: PendingInvite | null, setServer: (s: string) => void, setEmail: (f: (e: string) => string) => void) {
+ const server = invite?.server;
+ const email = invite?.preview?.email;
+ useEffect(() => {
+ if (server) setServer(server);
+ }, [server]);
+ useEffect(() => {
+ if (email) setEmail((e) => e || email);
+ }, [email]);
+}
+
+function SignIn({ onDone, choice, notice, invite, after }: StartProps & { onDone: () => void }) {
const [recover, setRecover] = useState(false);
- const [server, setServer] = useState(HOSTED_SERVER);
- const [email, setEmail] = useState("");
+ const [server, setServer] = useState(invite?.server ?? HOSTED_SERVER);
+ const [email, setEmail] = useState(invite?.preview?.email ?? "");
+ useInviteFill(invite, setServer, setEmail);
const [password, setPassword] = useState("");
const [code, setCode] = useState("");
const [confirm, setConfirm] = useState("");
@@ -199,7 +379,9 @@ function SignIn({ onDone, onBack, notice }: { onDone: () => void; onBack: () =>
return (
s || onBack()} />}
+ invite={invite && }
+ choice={choice}
+ after={after}
title={recover ? "Reset password and sign in" : "Sign in to sync"}
lead={
recover
@@ -255,7 +437,6 @@ function SignIn({ onDone, onBack, notice }: { onDone: () => void; onBack: () =>
);
}
-/** `onDone(true)`: a new vault; `onDone(false)`: one joined through sync. */
/** Why this device was erased (`device-erased`): it's back at the start. */
export interface Erased {
reason: "signedOut" | "disconnected";
@@ -265,17 +446,187 @@ export interface Erased {
function erasedNotice(e: Erased): string {
const account = e.email ?? "its sync account";
return e.reason === "signedOut"
- ? `This device was signed out of ${account} from another device, so MorphGate removed everything it kept here. To use the account again, sign in under “I have an account”.`
- : `MorphGate removed everything it kept on this device. Your vaults are still in ${e.email ?? "your sync account"}: sign in under “I have an account” to get them back.`;
+ ? `This device was signed out of ${account} from another device, so MorphGate removed everything it kept here. To use the account again, choose “Sign in”.`
+ : `MorphGate removed everything it kept on this device. Your vaults are still in ${e.email ?? "your sync account"}: choose “Sign in” to get them back.`;
+}
+
+/** A new vault and the account that syncs it, in one go: the vault is created here, then the
+ * account with the same master password (after the emailed code, on servers that check). */
+function NewAccount({
+ onDone,
+ onSignIn,
+ choice,
+ notice,
+ invite,
+ after,
+}: StartProps & { onDone: (created: boolean) => void; onSignIn: () => void }) {
+ const [server, setServer] = useState(invite?.server ?? HOSTED_SERVER);
+ const [checked, setChecked] = useState(null);
+ const [email, setEmail] = useState(invite?.preview?.email ?? "");
+ const [password, setPassword] = useState("");
+ const [confirm, setConfirm] = useState("");
+ const [accepted, setAccepted] = useState(false);
+ // Servers that verify addresses mail a code first; null until it was sent.
+ const [emailCode, setEmailCode] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [error, setError] = useState("");
+ // The vault's recovery key once it's created (before the account, which may still fail).
+ const [created, setCreated] = useState(null);
+ // Shown once everything is done (or the account is skipped).
+ const [recovery, setRecovery] = useState<{ code: string; account: boolean } | null>(null);
+ useInviteFill(invite, setServer, setEmail);
+ const strength = usePasswordStrength(password);
+ const terms = checked?.terms ?? null;
+
+ // What the server asks for (its terms, a mailed code) shows before anything is typed.
+ useEffect(() => {
+ let live = true;
+ setChecked(null);
+ const t = setTimeout(() => void sync.check(server).then((c) => live && setChecked(c), () => {}), server === HOSTED_SERVER ? 0 : 600);
+ return () => {
+ live = false;
+ clearTimeout(t);
+ };
+ }, [server]);
+
+ const ok =
+ server.trim() !== "" &&
+ email.includes("@") &&
+ (created !== null || (!!strength?.ok && password === confirm)) &&
+ (terms === null || accepted) &&
+ (emailCode === null || isTotpCode(emailCode));
+
+ const submit = async (e: FormEvent) => {
+ e.preventDefault();
+ if (!ok) return;
+ setBusy(true);
+ setError("");
+ try {
+ const caps = checked ?? (await sync.check(server));
+ setChecked(caps);
+ if (caps.terms && !accepted) return setError("Accept the terms of use and the privacy policy first.");
+ if (emailCode === null && caps.emailVerification) {
+ await sync.startRegistration(server, email);
+ setEmailCode("");
+ return;
+ }
+ const code = created ?? (await vault.create(password));
+ setCreated(code);
+ const r = await sync.enable(server, email, password, emailCode ?? undefined, caps.terms && accepted ? caps.terms.version : undefined);
+ setPassword("");
+ setConfirm("");
+ setRecovery({ code: r.recoveryCode ?? code, account: true });
+ } catch (err) {
+ setError(errorText(err));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ if (recovery) {
+ return (
+
+ onDone(!recovery.account)} />
+
+ );
+ }
+
+ const asking = emailCode === null && !!checked?.emailVerification;
+ return (
+ }
+ choice={choice}
+ after={after}
+ title="Create your account"
+ lead="A vault encrypted on this device with your master password, kept on your other devices through sync. The server stores only ciphertext."
+ >
+ void submit(e)}>
+ (setServer(v), setEmailCode(null), setAccepted(false))} />
+ {checked && !checked.registrationOpen && This server isn't accepting new accounts.
}
+
+ Email
+ (setEmail(e.target.value), setEmailCode(null))}
+ autoComplete="username"
+ autoFocus={!onPhone && !invite}
+ />
+
+ {!created && (
+
+ )}
+
+ {created
+ ? "Your vault is ready, with the master password you chose. The account gets the same one."
+ : "Your master password also signs you in to your account. It never leaves this device: only a key derived from it does."}
+
+ {confirm && password !== confirm && !created && Passwords don't match.
}
+ {emailCode !== null && (
+ <>
+
+
+ Didn't get it? Check spam, or{" "}
+ sync.startRegistration(server, email).catch((err) => setError(errorText(err)))}
+ >
+ send a new code
+
+ . If this address already has an account, you got an email saying so instead
+ {created ? (
+ "."
+ ) : (
+ <>
+ :{" "}
+
+ sign in to it
+
+ .
+ >
+ )}
+
+ >
+ )}
+ {terms && }
+ {error && {error}
}
+
+ {busy ? (asking ? "Sending code…" : created ? "Creating account…" : "Creating vault and account…") : asking ? "Continue" : "Create account"}
+
+ {created && (
+ setRecovery({ code: created, account: false })}>
+ Use the vault without an account for now
+
+ )}
+
+
+ );
}
+/** `onDone(true)`: a new vault without sync (sync is offered a week later); `onDone(false)`: sync is on. */
export function SetupScreen({ onDone, erased }: { onDone: (created: boolean) => void; erased?: Erased | null }) {
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [code, setCode] = useState(null);
- const [signIn, setSignIn] = useState(false);
+ const invite = usePendingInvite();
+ const [start, setStart] = useState("vault");
+ // An invite needs an account: it's what the screen offers first, until something is chosen.
+ const chose = useRef(false);
+ useEffect(() => {
+ if (invite && !chose.current) setStart("account");
+ }, [invite?.token]);
const strength = usePasswordStrength(password);
const ok = !!strength?.ok && password === confirm;
@@ -297,7 +648,21 @@ export function SetupScreen({ onDone, erased }: { onDone: (created: boolean) =>
};
const notice = erased ? erasedNotice(erased) : undefined;
- if (signIn) return onDone(false)} onBack={() => setSignIn(false)} notice={notice} />;
+ const choice = ((chose.current = true), setStart(s))} />;
+ const after = invite ? undefined : ;
+ if (start === "signIn") return onDone(false)} choice={choice} notice={notice} invite={invite} after={after} />;
+ if (start === "account") {
+ return (
+ ((chose.current = true), setStart("signIn"))}
+ choice={choice}
+ notice={notice}
+ invite={invite}
+ after={after}
+ />
+ );
+ }
if (code) {
return (
@@ -314,7 +679,9 @@ export function SetupScreen({ onDone, erased }: { onDone: (created: boolean) =>
return (
}
+ invite={invite && }
+ choice={choice}
+ after={after}
title="Create your vault"
lead="Hosts, keys and passwords are encrypted on this device with your master password. It never leaves this device."
>
diff --git a/apps/desktop/src/VpnPanel.tsx b/apps/desktop/src/VpnPanel.tsx
index f4baf98..0351267 100644
--- a/apps/desktop/src/VpnPanel.tsx
+++ b/apps/desktop/src/VpnPanel.tsx
@@ -3,6 +3,7 @@ import { errorText, OC_PROTOCOLS, VPN_KINDS, vpnKindName, vpns, type HostView, t
import { FilePick, type PickedFile } from "./FilePick";
import { isReadOnly, personalVault, VaultField } from "./VaultField";
import { mobileOs } from "./mobile/useMobile";
+import { copyText } from "./clipboard";
interface Props {
vpn?: VpnView;
@@ -48,6 +49,7 @@ export function VpnPanel({ vpn, hosts, vaults, onSaved, onChanged, onDeleted, on
const [error, setError] = useState("");
const [test, setTest] = useState(null);
const [copied, setCopied] = useState(false);
+ const [copyFailed, setCopyFailed] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const users = vpn ? hosts.filter((h) => h.vpnId === vpn.id) : [];
@@ -149,8 +151,13 @@ export function VpnPanel({ vpn, hosts, vaults, onSaved, onChanged, onDeleted, on
Your key
{vpn.publicKey}
- void navigator.clipboard?.writeText(vpn.publicKey ?? "").then(() => setCopied(true))}>
- {copied ? "✓" : "⧉"}
+ void copyText(vpn.publicKey ?? "").then((ok) => (ok ? setCopied(true) : setCopyFailed(true)))}
+ >
+ {copied ? "✓" : copyFailed ? "!" : "⧉"}
>
diff --git a/apps/desktop/src/api.ts b/apps/desktop/src/api.ts
index 8d79933..fb98538 100644
--- a/apps/desktop/src/api.ts
+++ b/apps/desktop/src/api.ts
@@ -117,6 +117,8 @@ export interface PasswordStrength {
export const vault = {
/** How strong a master password would be (desktop and phone check alike). */
strength: (password: string) => invoke("password_strength", { password }),
+ /** Five random words from EFF's long wordlist: a master password to suggest. */
+ suggest: () => invoke("password_suggest"),
status: () => invoke("vault_status"),
/** Resolves to the recovery code, shown once. */
create: (password: string) => invoke("vault_create", { password }),
@@ -261,6 +263,8 @@ export interface HostView {
state: string | null;
/** Reached through its cloud. */
via: CloudReachKind | null;
+ /** The feed that lists its host keys (they decide, rather than a pinned key), if one does. */
+ listedBy: string | null;
} | null;
groupId: string | null;
/** Shared credentials it signs in with. */
@@ -423,6 +427,10 @@ export interface SnippetView {
body: string;
tags: string[];
updatedAt: number;
+ /** The feed that keeps it (it's read-only here), if one does. */
+ source: { sourceId: string; label: string } | null;
+ /** Its feed changed the body since this device last pasted or ran it: ask first. */
+ changed: boolean;
}
export interface SnippetInput {
@@ -461,6 +469,8 @@ export const snippets = {
list: () => invoke("snippets_list"),
save: (input: SnippetInput) => invoke("snippet_save", { input }),
remove: (id: string) => invoke("snippet_delete", { id }),
+ /** This device pasted or ran a feed's snippet (what a later change of it is compared with). */
+ sent: (id: string) => invoke("snippet_sent", { id }),
/** The snippets in a CSV file's text (Name, Command, Tags columns); `name` is the file's. */
readCsv: (name: string, text: string) => invoke("snippets_read_csv", { name, text }),
/** Saves an example of that CSV in the downloads folder; says where. */
@@ -525,6 +535,8 @@ export interface SyncStatus {
syncing: boolean;
/** Sync is on but the server ended this device's session: sign in again. */
signedOut: boolean;
+ /** Organizations whose vaults wait for this account to turn on two-step verification. */
+ totpRequired: SsoNeed[];
/** Organizations whose vaults wait for this device to sign in at their identity provider. */
ssoRequired: SsoNeed[];
/** Organizations whose vaults wait for this device to be approved, or that denied it. */
@@ -567,6 +579,13 @@ export interface SsoNeed {
orgName: string;
}
+/** Which teams wait for two-step verification, and what for. */
+export function totpTitle(orgs: SsoNeed[]): string {
+ const names = orgs.map((o) => o.orgName);
+ const list = names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}` : names[0];
+ return `${list} ${names.length > 1 ? "require" : "requires"} two-step verification: ${names.length > 1 ? "their" : "its"} vaults open here once your account has it`;
+}
+
/** An organization's identity provider and this device's session with it. */
export interface SsoView {
configured: boolean;
@@ -872,6 +891,8 @@ export const teams = {
invites: (orgId: string) => invoke("org_invites", { orgId }),
cancelInvite: (orgId: string, inviteId: string) => invoke("org_invite_cancel", { orgId, inviteId }),
preview: (token: string) => invoke("invite_preview", { token }),
+ /** What an invite is for, before there's an account (no sign-in needed). */
+ peek: (server: string, token: string) => invoke("invite_peek", { server, token }),
/** `fingerprint`: the owner's, as compared; joining fails if it no longer matches. */
accept: (token: string, fingerprint: string) => invoke("invite_accept", { token, fingerprint }),
confirm: (orgId: string, accountId: string, fingerprint: string) =>
@@ -919,6 +940,11 @@ export interface ReceivedLink {
export const links = {
/** The links received since the last call (waiting while the vault was locked). */
take: () => invoke("links_take"),
+ /** The same, left waiting (the first screen shows an invite before there's a vault). */
+ peek: () => invoke("links_peek"),
+ /** An invite pasted on the first screen (its code, its web page, or a morphgate:// link): it
+ * waits with the other links, for Teams once there's an account. */
+ addInvite: (text: string) => invoke("links_add_invite", { text }),
};
// ------------------------------------------------------------------ SSH agents
@@ -1112,7 +1138,7 @@ export interface Reach {
/** A secret the member could read: change it after they leave. */
export interface Secret {
- kind: "password" | "key" | "vpn";
+ kind: "password" | "key" | "vpn" | "feed";
itemId: string;
label: string;
vaultName: string;
@@ -1425,7 +1451,7 @@ export const openLink = (url: string) => invoke("open_link", { url });
// ------------------------------------------------------------------ Cloud sources
-export type CloudProvider = "vallic" | "aws" | "gcp" | "azure" | "hetzner" | "digitalocean";
+export type CloudProvider = "vallic" | "aws" | "gcp" | "azure" | "hetzner" | "digitalocean" | "feed";
/** Reaching a machine through its cloud rather than an address. */
export type CloudReachKind = "aws-ssm" | "aws-eice" | "gcp-iap";
@@ -1444,10 +1470,12 @@ export const PROVIDERS: Record
invoke<{ source: CloudSource; refresh: CloudRefresh }>("cloud_source_add", { provider, settings, vaultId: opts.vaultId ?? null, keyId: opts.keyId ?? null }),
settings: (sourceId: string, settings: SourceSettings) => invoke("cloud_source_settings", { sourceId, settings }),
+ /** A feed, read once with its token before it's kept, then brought in. */
+ addFeed: (input: FeedInput, opts: { vaultId?: string | null; keyId?: string | null } = {}) =>
+ invoke<{ source: CloudSource; refresh: CloudRefresh }>("cloud_feed_add", { input, vaultId: opts.vaultId ?? null, keyId: opts.keyId ?? null }),
+ feedSettings: (sourceId: string, input: FeedInput) => invoke("cloud_feed_settings", { sourceId, input }),
+ /** Refreshes the feeds not refreshed in the last 10 minutes (desktop); how many it did. */
+ autoRefresh: () => invoke("cloud_auto_refresh"),
};
export const errorText = (e: unknown) => (typeof e === "string" ? e : e instanceof Error ? e.message : String(e));
diff --git a/apps/desktop/src/clipboard.ts b/apps/desktop/src/clipboard.ts
new file mode 100644
index 0000000..e04e440
--- /dev/null
+++ b/apps/desktop/src/clipboard.ts
@@ -0,0 +1,25 @@
+/** Puts `text` on the clipboard: through the Clipboard API, else the older copy command (WebKit
+ * refuses the first where it doesn't count the click as the person's, or without a permission).
+ * False when neither worked, so the button can say so instead of nothing happening. */
+export async function copyText(text: string): Promise {
+ try {
+ await navigator.clipboard.writeText(text);
+ return true;
+ } catch {
+ // The older way, below.
+ }
+ const area = document.createElement("textarea");
+ area.value = text;
+ area.setAttribute("readonly", "");
+ area.style.position = "fixed";
+ area.style.opacity = "0";
+ document.body.appendChild(area);
+ area.select();
+ try {
+ return document.execCommand("copy");
+ } catch {
+ return false;
+ } finally {
+ area.remove();
+ }
+}
diff --git a/apps/desktop/src/mobile/MobileWorkspace.tsx b/apps/desktop/src/mobile/MobileWorkspace.tsx
index 780c460..5d577b3 100644
--- a/apps/desktop/src/mobile/MobileWorkspace.tsx
+++ b/apps/desktop/src/mobile/MobileWorkspace.tsx
@@ -4,6 +4,7 @@ import {
describeForward,
hosts as hostsApi,
keyType,
+ totpTitle,
vpnKindName,
type ConnectTarget,
type ForwardView,
@@ -79,11 +80,14 @@ interface Props {
onToggleForward: (f: ForwardView) => void;
onOpenMore: (panel: MorePanel) => void;
onLock: () => void;
+ /** Turn on two-step verification: organizations' vaults wait for it. */
+ onTotp: () => void;
/** Sign in at an organization's identity provider. */
onSso: (need: SsoNeed) => void;
/** An organization's vaults wait for this device's approval. */
onApproval: (need: ApprovalNeed) => void;
- onSend: (key: number, body: string, run: boolean) => void;
+ /** Text into a session; `snippet` where it's one (a feed's may ask first). */
+ onSend: (key: number, body: string, run: boolean, snippet?: SnippetView) => void;
onKey: (key: number, k: ExtraKey) => void;
/** Latches Ctrl for the next key typed in the session; `used` is called when it applies. */
onCtrl: (key: number, on: boolean, used: () => void) => void;
@@ -225,6 +229,17 @@ function Home(p: Props & { section: Section; onSection: (s: Section) => void; on
{p.section === "hosts" && (
<>
+ {!!p.syncStatus?.totpRequired.length && (
+
+
+ Turn on two-step verification
+ {totpTitle(p.syncStatus.totpRequired)}.
+
+
+
+
+
+ )}
{p.syncStatus?.ssoRequired.map((n) => (
p.onSso(n)}>
@@ -739,14 +754,14 @@ function TerminalBar(p: Props & { tab: MobileTab }) {
type="button"
className="m-row-main"
disabled={p.tab.status !== "connected"}
- onClick={() => (close(), p.onSend(p.tab.key, s.body, false))}
+ onClick={() => (close(), p.onSend(p.tab.key, s.body, false, s))}
>
{s.name}
{s.body.split("\n")[0]}
-
(close(), p.onSend(p.tab.key, s.body, true))}>
+ (close(), p.onSend(p.tab.key, s.body, true, s))}>
Run
diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css
index cb39c75..4476b58 100644
--- a/apps/desktop/src/styles.css
+++ b/apps/desktop/src/styles.css
@@ -481,6 +481,13 @@ button.with-icon {
color: var(--ink-muted);
}
+/* A suggested master password, in the clear while both fields hold it. */
+.suggested-password .mono {
+ font-size: 15px;
+ overflow-wrap: anywhere;
+ user-select: all;
+}
+
.meter {
display: grid;
grid-template-columns: repeat(3, 1fr);
@@ -688,67 +695,6 @@ button.icon:hover {
text-align: left;
}
-.avatar {
- position: relative;
- display: grid;
- flex: none;
- place-items: center;
- width: 24px;
- height: 24px;
- border-radius: var(--radius-xs);
- background: var(--surface-raised);
- color: var(--ink-muted);
- font-family: var(--font-display);
- font-size: 11px;
- font-weight: 600;
-}
-
-.avatar.on {
- background: var(--gate);
- color: var(--on-gate);
-}
-
-/* A host with an open session: a status square on the avatar's corner. */
-.avatar .live {
- position: absolute;
- right: -3px;
- bottom: -3px;
- width: 8px;
- height: 8px;
- border-radius: 2px;
- background: var(--gate);
- box-shadow: 0 0 0 2px var(--surface);
-}
-
-.avatar .live.connecting {
- background: var(--cursor);
- animation: blink 1s steps(1) infinite;
-}
-
-.avatar.key {
- background: var(--surface-raised);
- color: var(--ink-muted);
-}
-
-.avatar.snippet {
- background: var(--surface-raised);
- color: var(--ink-muted);
- font-family: var(--font-mono);
- font-size: 10px;
-}
-
-.avatar.tunnel {
- background: var(--surface-raised);
- color: var(--ink-muted);
- font-family: var(--font-sans);
- font-size: 13px;
-}
-
-.avatar.tunnel.on {
- background: var(--gate-soft);
- color: var(--gate-ink);
-}
-
button.switch {
position: relative;
flex: none;
@@ -847,16 +793,6 @@ button.switch.busy i {
font-weight: 600;
}
-.avatar.vpn {
- background: var(--surface-raised);
- color: var(--ink-muted);
-}
-
-.avatar.vpn.on {
- background: var(--gate-soft);
- color: var(--gate-ink);
-}
-
.key-line {
display: flex;
align-items: center;
@@ -1772,6 +1708,12 @@ dialog.modal .actions {
color: var(--ink);
}
+.sidebar-foot .grow {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
/* First run: sync offered in a dialog, the same form as the Sync page. */
dialog.sheet {
width: min(520px, calc(100% - 32px));
@@ -1832,6 +1774,12 @@ dialog.sheet .actions .hint {
margin: 2px 0 0;
}
+/* Documentation, source code, licenses: the links' text lines up with the text above. */
+.about-links {
+ gap: 2px 14px;
+ margin-left: -4px;
+}
+
.update-notes {
max-height: 180px;
overflow: auto;
@@ -3213,6 +3161,28 @@ dialog.modal.approval {
width: min(520px, calc(100% - 32px));
}
+/* A feed's snippet that changed since it was last sent from this device. */
+dialog.modal.snippet-check {
+ width: min(560px, calc(100% - 32px));
+}
+
+dialog.modal.snippet-check .snippet-body {
+ max-height: 240px;
+ margin: 0 0 16px;
+ padding: 10px 12px;
+ overflow: auto;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-s);
+ background: var(--bg);
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+/* A feed's address on its source card. */
+.cloud-source .cloud-url {
+ overflow-wrap: anywhere;
+}
+
/* Field headings that aren't labels (a choice, a list). */
.field-label {
color: var(--muted);
@@ -3360,122 +3330,10 @@ button.nav-row.current {
color: var(--gate-ink);
}
-/* The list pages' rows: an avatar, the item, its buttons. */
-.item-list li {
- flex-wrap: nowrap;
-}
-
-.list-avatar {
- position: relative;
- display: grid;
- flex: none;
- place-items: center;
- width: 28px;
- height: 28px;
- border-radius: var(--radius-xs);
- background: var(--surface-raised);
- color: var(--ink-muted);
- font-family: var(--font-display);
- font-size: 12px;
- font-weight: 600;
-}
-
-.item-list li.on .list-avatar {
- background: var(--gate);
- color: var(--on-gate);
-}
-
-.list-avatar .live {
- position: absolute;
- right: -3px;
- bottom: -3px;
- width: 8px;
- height: 8px;
- border-radius: 2px;
- background: var(--gate);
- box-shadow: 0 0 0 2px var(--surface);
-}
-
-.list-avatar .live.connecting {
- background: var(--cursor);
-}
-
-.item-list li > div {
- gap: 2px 6px;
-}
-
-.item-list li > div > strong {
- margin-right: 2px;
-}
-
-.item-list small .mono,
-.item-list small.mono {
- font-family: var(--font-mono);
-}
-
-.list-buttons {
- display: flex;
- flex: none;
- flex-wrap: wrap;
- align-items: center;
- justify-content: flex-end;
- gap: 6px;
-}
-
-/* Narrow: the buttons go under the item. */
-@container page (max-width: 560px) {
- .item-list li {
- flex-wrap: wrap;
- }
-
- .list-buttons {
- flex-basis: 100%;
- justify-content: flex-start;
- padding-left: 40px;
- }
-}
-
-.item-list .vault-tag,
-.item-list .tag,
-.group-title .vault-tag {
- flex: none;
- padding: 0 5px;
- border: 1px solid var(--line-strong);
- border-radius: var(--radius-xs);
- color: var(--ink-muted);
- font-size: 11px;
- line-height: 16px;
- font-weight: 600;
-}
-
-.item-list .tag {
- border-style: dashed;
- font-weight: 400;
-}
-
-.item-list .kind-badge {
- margin: 0;
-}
-
.error-text {
color: var(--danger);
}
-.group-title {
- display: flex;
- align-items: center;
- gap: 8px;
- color: var(--ink-muted);
-}
-
-.group-title h2 {
- margin: 0;
- color: var(--ink);
- font-size: 15px;
- line-height: 22px;
- font-weight: 700;
-}
-
.page-main > section.stack {
gap: 8px;
}
@@ -3548,23 +3406,6 @@ dialog.sheet.move-dialog {
padding: 0 10px;
}
-/* A title that opens what it names (a group on the Hosts and Groups pages). */
-button.title-link {
- padding: 0;
- border: 0;
- background: none;
- color: inherit;
- font: inherit;
- text-align: left;
- cursor: pointer;
-}
-
-button.title-link:hover {
- color: var(--gate-ink);
- text-decoration: underline;
- text-underline-offset: 3px;
-}
-
.aside-list {
display: flex;
flex-direction: column;
@@ -3796,8 +3637,7 @@ fieldset.plain:disabled {
}
/* A host its cloud source no longer lists: kept, marked, for its owner to remove. */
-.kind-badge.gone,
-.item-list .kind-badge.gone {
+.kind-badge.gone {
border-color: var(--danger);
background: var(--danger-soft);
color: var(--danger);
@@ -3859,16 +3699,7 @@ fieldset.plain:disabled {
margin: 0;
}
-.item-list li.picking {
- cursor: pointer;
- user-select: none;
-}
-
-.item-list li.picked {
- background: var(--gate-soft);
-}
-
-.item-list input.pick {
+input.pick {
flex: none;
width: 16px;
height: 16px;
@@ -3880,3 +3711,825 @@ fieldset.plain:disabled {
.cloud-source .section-title .kind-badge {
margin-left: 8px;
}
+
+/* ------------------------------------------------------------ list pages: tables (Lists.tsx) */
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ margin: -1px;
+ padding: 0;
+ overflow: hidden;
+ clip: rect(0 0 0 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+button.icon.page-close {
+ width: 32px;
+ height: 32px;
+}
+
+button.quiet {
+ border: 1px solid transparent;
+ background: none;
+ color: var(--ink-muted);
+}
+
+button.quiet:hover:not(:disabled) {
+ background: var(--surface-raised);
+ color: var(--ink);
+}
+
+.ltoolbar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+
+.ltoolbar .search {
+ flex: 0 1 340px;
+ min-width: 200px;
+ height: var(--control-m);
+ margin: 0;
+}
+
+.filters {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+button.filter {
+ height: 28px;
+ padding: 0 10px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-s);
+ background: none;
+ color: var(--ink-muted);
+ font-size: 12px;
+}
+
+button.filter b {
+ margin-left: 6px;
+ color: var(--ink);
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
+
+button.filter:hover:not(.on) {
+ background: var(--surface-raised);
+ color: var(--ink);
+}
+
+button.filter.on {
+ border-color: var(--gate-ink);
+ background: var(--gate-soft);
+ color: var(--ink);
+}
+
+/* A filter with more choices than buttons fit (a vault, a group, a host). */
+select.filter-select {
+ width: auto;
+ max-width: 220px;
+ height: 28px;
+ padding-block: 0;
+ font-size: 12px;
+}
+
+select.filter-select.on {
+ border-color: var(--gate-ink);
+ background-color: var(--gate-soft);
+}
+
+.lrow.current {
+ background: var(--gate-soft);
+}
+
+/* A choice inside a table cell (a member's role): as wide as its column, no wider. */
+select.cell-select {
+ width: 100%;
+ min-width: 0;
+ height: 30px;
+ padding-block: 0;
+ font-size: 13px;
+}
+
+.add-from {
+ position: relative;
+}
+
+.add-from > button {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.add-from > button svg {
+ transform: rotate(90deg);
+}
+
+.add-from .menu {
+ width: 320px;
+}
+
+/* Quick connect on the Hosts page. */
+.quick-bar {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px 12px;
+ padding: 10px 12px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-m);
+ background: var(--surface);
+}
+
+.quick-bar input {
+ flex: 1 1 240px;
+ min-width: 0;
+}
+
+.quick-hint {
+ flex-basis: 100%;
+}
+
+/* Tabs within a page (Keys, Identities, Host CAs). */
+.page-tabs {
+ display: flex;
+ gap: 2px;
+ margin-top: -8px;
+ border-bottom: 1px solid var(--line);
+}
+
+button.page-tab {
+ position: relative;
+ height: 36px;
+ padding: 0 12px;
+ border: 0;
+ background: none;
+ color: var(--ink-muted);
+}
+
+button.page-tab b {
+ margin-left: 6px;
+ color: var(--ink-muted);
+ font-weight: 600;
+ font-variant-numeric: tabular-nums;
+}
+
+button.page-tab:hover:not(.on) {
+ color: var(--ink);
+}
+
+button.page-tab.on {
+ color: var(--ink);
+ font-weight: 600;
+}
+
+button.page-tab.on::after {
+ content: "";
+ position: absolute;
+ right: 8px;
+ bottom: -1px;
+ left: 8px;
+ height: 2px;
+ background: var(--gate);
+}
+
+.ltable {
+ overflow-x: auto;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-m);
+ background: var(--surface);
+}
+
+.lthead,
+.lrow {
+ display: grid;
+ grid-template-columns: var(--cols);
+ align-items: center;
+ column-gap: 16px;
+ padding: 0 12px;
+}
+
+.lthead {
+ height: 32px;
+ border-bottom: 1px solid var(--line);
+}
+
+.lrow {
+ min-height: 44px;
+ padding-block: 4px;
+}
+
+.lrow.band {
+ background: var(--band);
+}
+
+.lrow:hover {
+ background: var(--surface-raised);
+}
+
+.lrow.picking {
+ cursor: pointer;
+ user-select: none;
+}
+
+.lrow.picked {
+ background: var(--gate-soft);
+}
+
+.lcell {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+}
+
+.lstack {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ min-width: 0;
+ overflow: hidden;
+}
+
+.lcell.num {
+ font-variant-numeric: tabular-nums;
+}
+
+.lfirst {
+ gap: 10px;
+}
+
+.lfirst .pick {
+ flex: none;
+}
+
+.lnamebtn {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-width: 0;
+ max-width: 100%;
+ overflow: hidden;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+}
+
+button.lnamebtn:hover .lname {
+ text-decoration: underline;
+ text-decoration-color: var(--line-strong);
+ text-underline-offset: 3px;
+}
+
+.lnamebtn > svg {
+ flex: none;
+ color: var(--ink-muted);
+}
+
+.lname-row {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ min-width: 0;
+ /* An address has no place to break: without this the row is as wide as it, and the name
+ can't shrink to an ellipsis. */
+ max-width: 100%;
+}
+
+.lname {
+ flex: 0 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ font-weight: 600;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* What goes beside a name shrinks too, so nothing runs into the next column. */
+.lname-row > .tag,
+.lname-row > .vault-tag,
+.lname-row > .kind-badge {
+ flex: 0 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.lsub {
+ overflow: hidden;
+ max-width: 100%;
+ color: var(--ink-muted);
+ font-size: 12px;
+ line-height: 16px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.lsub.error-text {
+ color: var(--danger);
+}
+
+.trunc {
+ overflow: hidden;
+ min-width: 0;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.muted {
+ color: var(--ink-muted);
+}
+
+.ltags {
+ flex-wrap: wrap;
+ gap: 4px;
+}
+
+.ltable .tag,
+.ltable .vault-tag,
+.lghead .vault-tag,
+.use-card .tag {
+ flex: none;
+ padding: 0 5px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-xs);
+ color: var(--ink-muted);
+ font-family: var(--font-mono);
+ font-size: 11px;
+ line-height: 16px;
+ white-space: nowrap;
+}
+
+.ltable .vault-tag,
+.lghead .vault-tag {
+ border-color: var(--line-strong);
+ font-family: var(--font-sans);
+ font-weight: 600;
+}
+
+.ltable .tag.up,
+.use-card .tag.up {
+ border-color: var(--gate-ink);
+ color: var(--gate-ink);
+}
+
+.ltable .kind-badge {
+ margin: 0;
+}
+
+.ltable .lcell.mono,
+.ltable .lsub.mono,
+.ltable .lcell .mono {
+ font-size: 12px;
+}
+
+.rowact {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 2px;
+ opacity: 0;
+ transition: opacity 120ms ease-out;
+}
+
+.lrow:hover .rowact,
+.lrow:focus-within .rowact {
+ opacity: 1;
+}
+
+@media (hover: none) {
+ .rowact {
+ opacity: 1;
+ }
+}
+
+button.text-action {
+ height: 26px;
+ padding: 0 8px;
+ border: 0;
+ border-radius: var(--radius-s);
+ background: none;
+ color: var(--gate-ink);
+ font-size: 12px;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+button.text-action:hover {
+ background: var(--gate-soft);
+}
+
+button.text-action.warn {
+ color: var(--cursor-ink);
+}
+
+.lsection + .lsection {
+ border-top: 1px solid var(--line);
+}
+
+/* A group's hosts sit in from its header, a rule down from its icon, as in the sidebar. */
+.lsection.in-group .lrow {
+ position: relative;
+}
+
+.lsection.in-group .lrow > .lfirst {
+ padding-left: 22px;
+}
+
+.lsection.in-group .lrow::before {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 18px;
+ border-left: 1px solid var(--line-strong);
+ content: "";
+}
+
+.lghead {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 4px 10px;
+ box-sizing: border-box;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--line);
+ background: var(--ground);
+}
+
+.lgname {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: var(--ink);
+ font-weight: 600;
+}
+
+button.lgname:hover {
+ text-decoration: underline;
+ text-decoration-color: var(--line-strong);
+ text-underline-offset: 3px;
+}
+
+.lgname svg {
+ color: var(--ink-muted);
+}
+
+.lgcount {
+ color: var(--ink-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.defaults {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 2px 12px;
+ color: var(--ink-muted);
+ font-size: 12px;
+}
+
+.defaults .mono {
+ color: var(--ink);
+}
+
+.lempty,
+.page-pad {
+ margin: 0;
+}
+
+.lempty {
+ padding: 10px 12px;
+}
+
+/* A connection's state: a square, from the mark's cursor (shape and colour). */
+.sq {
+ flex: none;
+ width: 10px;
+ height: 10px;
+ box-sizing: border-box;
+ border-radius: 2px;
+}
+
+.sq.connected {
+ background: var(--gate);
+}
+
+.sq.connecting {
+ height: 4px;
+ background: var(--cursor);
+ animation: blink 1s steps(1) infinite;
+}
+
+.sq.idle {
+ border: 1.5px solid var(--line-strong);
+}
+
+/* A state in a word. */
+.chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ height: 22px;
+ padding: 0 8px 0 6px;
+ border-radius: var(--radius-xs);
+ background: var(--surface-raised);
+ color: var(--ink);
+ font-size: 12px;
+ font-weight: 600;
+ white-space: nowrap;
+}
+
+.chip i {
+ width: 8px;
+ height: 8px;
+ box-sizing: border-box;
+ border-radius: 2px;
+}
+
+.chip.connected {
+ background: var(--gate-soft);
+}
+
+.chip.connected i {
+ background: var(--gate);
+}
+
+.chip.idle i {
+ border: 1.5px solid var(--line-strong);
+}
+
+.chip.attention {
+ background: var(--cursor-soft);
+}
+
+.chip.attention i {
+ background: var(--cursor);
+}
+
+.chip.failed {
+ background: var(--danger-soft);
+}
+
+.chip.failed i {
+ border: 2px solid var(--danger);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .rowact {
+ transition: none;
+ }
+
+ .sq.connecting {
+ animation: none;
+ }
+}
+
+/* What a group's hosts use, on its page. */
+.use-cards {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
+ gap: 8px;
+}
+
+button.use-card {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 2px;
+ min-width: 0;
+ padding: 10px 12px;
+ border: 1px solid var(--line);
+ border-radius: var(--radius-m);
+ background: var(--surface);
+ text-align: left;
+}
+
+button.use-card:hover {
+ background: var(--surface-raised);
+}
+
+.use-name {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ max-width: 100%;
+ font-weight: 600;
+}
+
+.use-name svg {
+ flex: none;
+ color: var(--ink-muted);
+}
+
+/* Narrow pages (a phone, a small window): rows stack, their actions always shown. */
+@container page (max-width: 640px) {
+ .lthead {
+ display: none;
+ }
+
+ .lrow {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 2px 12px;
+ padding-block: 8px;
+ }
+
+ .lrow > .lfirst {
+ flex: 1 1 100%;
+ }
+
+ .lrow > .lcell:not(.lfirst) {
+ padding-left: 20px;
+ font-size: 12px;
+ }
+
+ .lsection.in-group .lrow > .lcell:not(.lfirst) {
+ padding-left: 42px;
+ }
+
+ .lrow > .lcell:empty {
+ display: none;
+ }
+
+ .rowact {
+ margin-left: auto;
+ opacity: 1;
+ }
+}
+
+/* The sidebar's host rows: the status square in the avatar's place. */
+.row-main > .sq {
+ margin-inline: 7px;
+}
+
+/* ------------------------------------------------------------ teams (TeamsPanel.tsx) */
+
+/* Joining with an invite, and this account's fingerprint, under the list of teams. */
+.teams-foot {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: 24px;
+}
+
+/* What waits for this account in a team, each with its action. */
+.needs {
+ display: flex;
+ flex-direction: column;
+ border: 1px solid var(--cursor-ink);
+ border-radius: var(--radius-m);
+ background: var(--cursor-soft);
+}
+
+.need-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px 12px;
+ padding: 10px 12px;
+}
+
+.need-row + .need-row {
+ border-top: 1px solid var(--line);
+}
+
+.need-row .sq {
+ align-self: flex-start;
+ margin-top: 5px;
+}
+
+.need-text {
+ flex: 1 1 320px;
+ min-width: 0;
+}
+
+.need-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+
+.needs > .error {
+ margin: 0;
+ padding: 0 12px 10px;
+}
+
+/* A row's details, opened under it (a vault's access, comparing a fingerprint). */
+.lexpand {
+ padding: 12px 12px 14px 40px;
+ border-bottom: 1px solid var(--line);
+ background: var(--ground);
+}
+
+.lexpand.stack {
+ gap: 10px;
+}
+
+.lexpand .hint {
+ margin: 0;
+}
+
+button.text-action.danger {
+ color: var(--danger);
+}
+
+button.text-action.danger:hover {
+ background: var(--danger-soft);
+}
+
+/* Settings: a row per setting, what it does under its name, its control on the right. */
+.settings {
+ border: 1px solid var(--line);
+ border-radius: var(--radius-m);
+ background: var(--surface);
+}
+
+.setting {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px 16px;
+ padding: 12px;
+}
+
+.setting + .setting {
+ border-top: 1px solid var(--line);
+}
+
+.setting-text {
+ flex: 1 1 300px;
+}
+
+.setting-name {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-weight: 600;
+}
+
+.setting-desc {
+ white-space: normal;
+}
+
+.setting-input {
+ width: 220px;
+ max-width: 100%;
+}
+
+.setting-input.days {
+ width: 80px;
+ text-align: right;
+}
+
+.settings-save {
+ position: sticky;
+ bottom: 0;
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 12px;
+ border: 1px solid var(--line-strong);
+ border-radius: var(--radius-m);
+ background: var(--surface-raised);
+ box-shadow: var(--shadow-pop);
+}
+
+.team-settings > .section-title {
+ margin-top: 8px;
+}
+
+.danger-title {
+ color: var(--danger);
+}
+
+.settings.danger-zone {
+ border-color: var(--danger);
+}
+
+dialog.sheet.invite-dialog {
+ width: min(560px, calc(100% - 32px));
+}
+
+.invite-dialog h1 {
+ margin: 0 0 16px;
+ font-family: var(--font-display);
+ font-size: 20px;
+ line-height: 26px;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+}
+
+.invite-sent {
+ gap: 4px;
+}
diff --git a/assets/brand/README.md b/assets/brand/README.md
index f31cd54..b2fa89a 100644
--- a/assets/brand/README.md
+++ b/assets/brand/README.md
@@ -12,6 +12,7 @@ the wordmark in a live font.
| `morphgate-android-foreground.svg`, `-background.svg`, `-monochrome.svg` | Android's adaptive icon layers: the mark alone, scaled to stay inside the circle launchers cut, on the tile's colour; one-colour for themed icons. |
| `morphgate-lockup-on-dark.svg` | On dark grounds (Console, terminal backgrounds). |
| `morphgate-lockup-on-light.svg` | On light grounds (Greenbar, paper). |
+| `morphgate-social-preview.png` | The repository's social preview on GitHub (Settings → General → Social preview), 1280 × 640: the lockup on the dark ground, the tagline, the platforms. |
The phone icons come from `icon-manifest.json` (Android) and the iOS tile, and go to both
`src-tauri/icons` and the generated projects (`gen/android`, `gen/apple`), which `tauri icon`
diff --git a/assets/brand/morphgate-social-preview.png b/assets/brand/morphgate-social-preview.png
new file mode 100644
index 0000000..25a8ddb
Binary files /dev/null and b/assets/brand/morphgate-social-preview.png differ
diff --git a/assets/demo/morphgate.svg b/assets/demo/morphgate.svg
new file mode 100644
index 0000000..a55bc39
--- /dev/null
+++ b/assets/demo/morphgate.svg
@@ -0,0 +1,12 @@
+
+MorphGate: connecting to web-1
+
+
+
+
+Hosts 5 Groups 2 Keys 1 Snippets Tunnels VPNs Session logs Search hosts OPEN web-1 deploy@10.0.12.34:22 HOSTS bastion deploy@bastion.shop.e… Production 3 db-1 deploy@10.0.12.40 web-1 deploy@10.0.12.34:… web-2 deploy@10.0.12.35 Staging 1 staging deploy@10.0.20.11 Sync off REC web-1 web-1 deploy@10.0.… Recording ED25519 SHA256:803o…W12k Files Split >_ Snippets deploy@web-1 : ~ $ deploy@web-1 : ~ $ u deploy@web-1 : ~ $ up deploy@web-1 : ~ $ upt deploy@web-1 : ~ $ upti deploy@web-1 : ~ $ uptim deploy@web-1 : ~ $ uptime 12:29:31 up 7 days, 22:16, 4 users, load average: 2.52, 4.16, 4.88 deploy@web-1 : ~ $ deploy@web-1 : ~ $ c deploy@web-1 : ~ $ cd deploy@web-1 : ~ $ cd s deploy@web-1 : ~ $ cd sh deploy@web-1 : ~ $ cd sho deploy@web-1 : ~ $ cd shop deploy@web-1 : ~ $ cd shop & deploy@web-1 : ~ $ cd shop && deploy@web-1 : ~ $ cd shop && g deploy@web-1 : ~ $ cd shop && gi deploy@web-1 : ~ $ cd shop && git deploy@web-1 : ~ $ cd shop && git l deploy@web-1 : ~ $ cd shop && git lo deploy@web-1 : ~ $ cd shop && git log deploy@web-1 : ~ $ cd shop && git log - deploy@web-1 : ~ $ cd shop && git log -- deploy@web-1 : ~ $ cd shop && git log --o deploy@web-1 : ~ $ cd shop && git log --on deploy@web-1 : ~ $ cd shop && git log --one deploy@web-1 : ~ $ cd shop && git log --onel deploy@web-1 : ~ $ cd shop && git log --oneli deploy@web-1 : ~ $ cd shop && git log --onelin deploy@web-1 : ~ $ cd shop && git log --oneline deploy@web-1 : ~ $ cd shop && git log --oneline - deploy@web-1 : ~ $ cd shop && git log --oneline -- deploy@web-1 : ~ $ cd shop && git log --oneline --d deploy@web-1 : ~ $ cd shop && git log --oneline --de deploy@web-1 : ~ $ cd shop && git log --oneline --dec deploy@web-1 : ~ $ cd shop && git log --oneline --deco deploy@web-1 : ~ $ cd shop && git log --oneline --decor deploy@web-1 : ~ $ cd shop && git log --oneline --decora deploy@web-1 : ~ $ cd shop && git log --oneline --decorat deploy@web-1 : ~ $ cd shop && git log --oneline --decorate deploy@web-1 : ~ $ cd shop && git log --oneline --decorate - deploy@web-1 : ~ $ cd shop && git log --oneline --decorate -4 fcad5ec ( HEAD -> main , tag: v2.4.1 ) TLS everywhere 4543d8c Run in a container 777e8c2 Serve it with nginx 47a7717 Start the shop deploy@web-1 : ~/shop $ deploy@web-1 : ~/shop $ l deploy@web-1 : ~/shop $ ls Dockerfile README.md config deploy@web-1 : ~/shop $
+Hosts 5 Groups 2 Keys 1 Snippets Tunnels VPNs Session logs Search hosts HOSTS bastion deploy@bastion.shop.e… Production 3 db-1 deploy@10.0.12.40 web-1 deploy@10.0.12.34:… web-2 deploy@10.0.12.35 Staging 1 staging deploy@10.0.20.11 Sync off Hosts Add from… Quick connect New group New host Close 5 hosts in 2 groups. Search by name, address, user or tag shop 3 postgres 1 Select HOST ROUTE SIGNS IN WITH TAGS Actions Not in a group 1 bastion deploy@bastion.shop.… direct key deploy Production 3 Edit group db-1 deploy@10.0.12.40:… direct key deploy postgres web-1 deploy@10.0.12.34… direct key deploy recorded shop web-2 deploy@10.0.12.35:… direct key deploy shop Staging 1 Edit group staging deploy@10.0.20.11:… direct key deploy shop
+
+
+
+
diff --git a/crates/mg-inventory/src/feed.rs b/crates/mg-inventory/src/feed.rs
new file mode 100644
index 0000000..f27e5d4
--- /dev/null
+++ b/crates/mg-inventory/src/feed.rs
@@ -0,0 +1,664 @@
+//! Feeds: hosts and snippets from a URL, as a JSON document a team publishes (from Terraform
+//! output, a CMDB, a cron job, an internal endpoint), read with a token (docs/features/
+//! cloud-sources.md, "Feeds"). A feed carries data only: nothing in it runs on the device (no
+//! proxy or local commands, no reaching through a cloud). Entries that don't hold up are skipped
+//! and said; a document that doesn't is refused whole, so a bad one changes nothing.
+
+use std::collections::{HashMap, HashSet};
+use std::time::Duration;
+
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use url::Url;
+
+use crate::{Error, InventoryHost, Result};
+
+/// The format this version reads.
+pub const VERSION: u64 = 1;
+/// The largest document read.
+pub const MAX_BYTES: usize = 5 * 1024 * 1024;
+pub const MAX_HOSTS: usize = 5_000;
+pub const MAX_SNIPPETS: usize = 1_000;
+/// The largest snippet body.
+pub const MAX_BODY: usize = 64 * 1024;
+/// How long a feed has to answer.
+pub const TIMEOUT: Duration = Duration::from_secs(60);
+
+/// An entry's `id`, and how long a label, group, tag or state may be.
+const MAX_ID: usize = 200;
+const MAX_TEXT: usize = 200;
+const MAX_TAG: usize = 64;
+const MAX_TAGS: usize = 50;
+const MAX_HOST_KEYS: usize = 16;
+
+/// Host key types a feed may list (OpenSSH's, without certificates).
+const KEY_TYPES: [&str; 7] = [
+ "ssh-ed25519",
+ "ecdsa-sha2-nistp256",
+ "ecdsa-sha2-nistp384",
+ "ecdsa-sha2-nistp521",
+ "ssh-rsa",
+ "sk-ssh-ed25519@openssh.com",
+ "sk-ecdsa-sha2-nistp256@openssh.com",
+];
+
+/// A snippet as a feed gives it.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct FeedSnippet {
+ /// `feed:`: what a refresh matches the vault's snippet on.
+ pub external_id: String,
+ pub name: String,
+ pub body: String,
+ pub tags: Vec,
+}
+
+/// What a feed lists.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct Feed {
+ /// What the feed calls itself (`"Vallic operations"`).
+ pub name: Option,
+ pub hosts: Vec,
+ pub snippets: Vec,
+ /// Entries left out, and why (`server-1234: the port isn't 1 to 65535`).
+ pub skipped: Vec,
+}
+
+fn local(url: &Url) -> bool {
+ matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]"))
+}
+
+/// A feed's address: `https://`, or `http://` on this computer.
+pub fn check_url(url: &str) -> Result {
+ let url = url.trim();
+ let parsed = Url::parse(url).map_err(|e| Error::Api(format!("{url}: {e}")))?;
+ if parsed.scheme() == "https" || (parsed.scheme() == "http" && local(&parsed)) {
+ Ok(parsed)
+ } else {
+ Err(Error::Api(format!(
+ "{url}: a feed's address starts with https://"
+ )))
+ }
+}
+
+/// Reads the feed at `url` with `token`. `name` is the source's, for messages.
+pub async fn fetch(name: &str, url: &str, token: &str) -> Result {
+ let url = check_url(url)?;
+ // Redirects stay on https (or this computer); the token isn't sent to another host.
+ let redirects = reqwest::redirect::Policy::custom(|attempt| {
+ let ok = attempt.url().scheme() == "https"
+ || (attempt.url().scheme() == "http" && local(attempt.url()));
+ if attempt.previous().len() >= 5 || !ok {
+ attempt.stop()
+ } else {
+ attempt.follow()
+ }
+ });
+ let http = reqwest::Client::builder()
+ .user_agent(concat!("MorphGate/", env!("CARGO_PKG_VERSION")))
+ .timeout(TIMEOUT)
+ .redirect(redirects)
+ .build()
+ .map_err(|e| Error::Api(e.to_string()))?;
+ let failed = |e: reqwest::Error| {
+ if e.is_timeout() {
+ Error::Api(format!("{name} took longer than 60 s to answer"))
+ } else {
+ Error::Unreachable(name.to_owned(), e.to_string())
+ }
+ };
+ let mut response = http
+ .get(url)
+ .bearer_auth(token)
+ .header("Accept", "application/json")
+ .send()
+ .await
+ .map_err(failed)?;
+ match response.status().as_u16() {
+ 200..=299 => {}
+ 401 | 403 => return Err(Error::TokenRefused(name.to_owned())),
+ status => {
+ return Err(Error::Api(
+ format!(
+ "{name} answered {status} {}",
+ response.status().canonical_reason().unwrap_or("")
+ )
+ .trim_end()
+ .to_owned(),
+ ));
+ }
+ }
+ let too_large = || {
+ Error::Api(format!(
+ "{name} sent more than 5 MiB: a feed is at most that"
+ ))
+ };
+ if response
+ .content_length()
+ .is_some_and(|n| n > MAX_BYTES as u64)
+ {
+ return Err(too_large());
+ }
+ let mut body = Vec::new();
+ while let Some(chunk) = response.chunk().await.map_err(failed)? {
+ if body.len() + chunk.len() > MAX_BYTES {
+ return Err(too_large());
+ }
+ body.extend_from_slice(&chunk);
+ }
+ parse(name, &body)
+}
+
+/// An optional field: absent and `null` both read as empty.
+fn nullable<'de, D, T>(d: D) -> std::result::Result
+where
+ D: serde::Deserializer<'de>,
+ T: Default + Deserialize<'de>,
+{
+ Ok(Option::::deserialize(d)?.unwrap_or_default())
+}
+
+#[derive(Deserialize)]
+struct RawHost {
+ id: String,
+ #[serde(default, deserialize_with = "nullable")]
+ label: String,
+ hostname: String,
+ #[serde(default)]
+ port: Option,
+ user: String,
+ #[serde(default, deserialize_with = "nullable")]
+ group: String,
+ #[serde(default, deserialize_with = "nullable")]
+ tags: Vec,
+ #[serde(default)]
+ jump: Option,
+ #[serde(default)]
+ key: Option,
+ #[serde(default, deserialize_with = "nullable")]
+ host_keys: Vec,
+ #[serde(default)]
+ state: Option,
+}
+
+#[derive(Deserialize)]
+struct RawSnippet {
+ id: String,
+ name: String,
+ body: String,
+ #[serde(default, deserialize_with = "nullable")]
+ tags: Vec,
+}
+
+/// `[A-Za-z0-9._:/@-]`, 1 to 200 characters.
+fn valid_id(id: &str) -> bool {
+ !id.is_empty()
+ && id.len() <= MAX_ID
+ && id
+ .bytes()
+ .all(|b| b.is_ascii_alphanumeric() || b"._:/@-".contains(&b))
+}
+
+/// A host name or user: no whitespace or control characters, and no leading `-` (it would read
+/// as an option).
+fn word(s: &str, what: &str) -> std::result::Result {
+ if s.is_empty() {
+ return Err(format!("no {what}"));
+ }
+ if s.len() > 255 || s.starts_with('-') || s.chars().any(|c| c.is_whitespace() || c.is_control())
+ {
+ return Err(format!("the {what} {s:?} isn't one SSH can use"));
+ }
+ Ok(s.to_owned())
+}
+
+/// Text to show: control characters out, trimmed, at most `max` characters.
+fn text(s: &str, max: usize) -> String {
+ let clean: String = s
+ .chars()
+ .map(|c| if c.is_control() { ' ' } else { c })
+ .collect();
+ clean
+ .trim()
+ .chars()
+ .take(max)
+ .collect::()
+ .trim()
+ .to_owned()
+}
+
+fn tags(raw: &[String]) -> Vec {
+ let mut out: Vec = Vec::new();
+ for t in raw {
+ let t = text(t, MAX_TAG);
+ if !t.is_empty() && !out.iter().any(|x| x.eq_ignore_ascii_case(&t)) {
+ out.push(t);
+ }
+ }
+ out.truncate(MAX_TAGS);
+ out
+}
+
+/// Standard base64, padded.
+fn base64(s: &str) -> bool {
+ !s.is_empty()
+ && s.len().is_multiple_of(4)
+ && s.trim_end_matches('=').len() + 2 >= s.len()
+ && s.trim_end_matches('=')
+ .bytes()
+ .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/')
+}
+
+/// A host key line as `type base64` (a comment after it is dropped).
+fn host_key(line: &str) -> std::result::Result {
+ let mut words = line.split_whitespace();
+ match (words.next(), words.next()) {
+ (Some(kind), Some(data)) if KEY_TYPES.contains(&kind) && base64(data) => {
+ Ok(format!("{kind} {data}"))
+ }
+ _ => Err(format!(
+ "the host key {:?} isn't an OpenSSH public key (type and base64)",
+ text(line, 40)
+ )),
+ }
+}
+
+fn host(raw: RawHost) -> std::result::Result<(String, InventoryHost), String> {
+ let hostname = word(raw.hostname.trim(), "host name")?;
+ let user = word(raw.user.trim(), "user")?;
+ let port = match raw.port {
+ None => 22,
+ Some(p @ 1..=65535) => p as u16,
+ Some(_) => return Err("the port isn't 1 to 65535".into()),
+ };
+ let jump = match raw.jump.as_deref().map(str::trim).filter(|j| !j.is_empty()) {
+ Some(j) if j == raw.id => return Err("it can't be its own jump host".into()),
+ Some(j) if !valid_id(j) => {
+ return Err(format!("the jump host {:?} isn't an id", text(j, 40)));
+ }
+ other => other.map(str::to_owned),
+ };
+ if raw.host_keys.len() > MAX_HOST_KEYS {
+ return Err(format!("more than {MAX_HOST_KEYS} host keys"));
+ }
+ let host_keys = raw
+ .host_keys
+ .iter()
+ .map(|k| host_key(k))
+ .collect::, _>>()?;
+ let label = match text(&raw.label, MAX_TEXT) {
+ l if l.is_empty() => raw.id.clone(),
+ l => l,
+ };
+ let state = raw
+ .state
+ .map(|s| text(&s, MAX_TAG))
+ .filter(|s| !s.is_empty() && !s.eq_ignore_ascii_case("running"));
+ Ok((
+ jump.unwrap_or_default(),
+ InventoryHost {
+ external_id: format!("feed:{}", raw.id),
+ group: text(&raw.group, MAX_TEXT),
+ label,
+ hostname,
+ port,
+ user,
+ login_command: None,
+ jump: None,
+ jump_command: None,
+ no_sftp: false,
+ no_forwarding: false,
+ state,
+ key_name: raw
+ .key
+ .map(|k| text(&k, MAX_TEXT))
+ .filter(|k| !k.is_empty()),
+ via: None,
+ host_keys,
+ tags: tags(&raw.tags),
+ },
+ ))
+}
+
+fn snippet(raw: RawSnippet) -> std::result::Result {
+ let name = text(&raw.name, MAX_TEXT);
+ if name.is_empty() {
+ return Err("no name".into());
+ }
+ if raw.body.trim().is_empty() {
+ return Err("no body".into());
+ }
+ if raw.body.len() > MAX_BODY {
+ return Err("the body is over 64 KiB".into());
+ }
+ // A terminal reads escape sequences as more than text.
+ if raw
+ .body
+ .chars()
+ .any(|c| c.is_control() && !matches!(c, '\n' | '\r' | '\t'))
+ {
+ return Err("the body has control characters".into());
+ }
+ Ok(FeedSnippet {
+ external_id: format!("feed:{}", raw.id),
+ name,
+ body: raw.body,
+ tags: tags(&raw.tags),
+ })
+}
+
+/// What an entry is called in messages: its id, else its place in the list.
+fn entry_name(v: &Value, kind: &str, n: usize) -> String {
+ match v.get("id").and_then(Value::as_str) {
+ Some(id) if valid_id(id) => id.to_owned(),
+ _ => format!("{kind} {}", n + 1),
+ }
+}
+
+/// Reads a feed document. `name` is the source's, for messages.
+pub fn parse(name: &str, bytes: &[u8]) -> Result {
+ let unreadable = |why: String| {
+ Error::Api(format!(
+ "{name} sent something this version can't read: {why}"
+ ))
+ };
+ let doc: Value = serde_json::from_slice(bytes).map_err(|e| unreadable(e.to_string()))?;
+ let obj = doc
+ .as_object()
+ .ok_or_else(|| unreadable("not a JSON object".into()))?;
+ match obj.get("version").and_then(Value::as_u64) {
+ Some(VERSION) => {}
+ Some(n) if n > VERSION => {
+ return Err(Error::Api(format!(
+ "{name} is a version {n} feed: update MorphGate to read it"
+ )));
+ }
+ _ => return Err(unreadable("no \"version\": 1".into())),
+ }
+ let list = |key: &str| match obj.get(key) {
+ None | Some(Value::Null) => Ok(&[][..]),
+ Some(Value::Array(a)) => Ok(a.as_slice()),
+ Some(_) => Err(unreadable(format!("\"{key}\" isn't a list"))),
+ };
+ let (raw_hosts, raw_snippets) = (list("hosts")?, list("snippets")?);
+ if raw_hosts.len() > MAX_HOSTS {
+ return Err(Error::Api(format!(
+ "{name} lists {} hosts: a feed holds at most 5,000",
+ raw_hosts.len()
+ )));
+ }
+ if raw_snippets.len() > MAX_SNIPPETS {
+ return Err(Error::Api(format!(
+ "{name} lists {} snippets: a feed holds at most 1,000",
+ raw_snippets.len()
+ )));
+ }
+ let mut feed = Feed {
+ name: obj
+ .get("name")
+ .and_then(Value::as_str)
+ .map(|n| text(n, MAX_TEXT))
+ .filter(|n| !n.is_empty()),
+ ..Feed::default()
+ };
+
+ // Hosts, with the id of the one each jumps through.
+ let mut hosts: Vec<(String, String, InventoryHost)> = Vec::new();
+ let mut seen = HashSet::new();
+ for (n, v) in raw_hosts.iter().enumerate() {
+ let entry = entry_name(v, "host", n);
+ let parsed = serde_json::from_value::(v.clone())
+ .map_err(|e| e.to_string())
+ .and_then(|raw| {
+ if !valid_id(&raw.id) {
+ return Err("its id isn't 1 to 200 of A-Z, a-z, 0-9 and . _ : / @ -".into());
+ }
+ if !seen.insert(raw.id.clone()) {
+ return Err("another host has this id".into());
+ }
+ let id = raw.id.clone();
+ host(raw).map(|(jump, h)| (id, jump, h))
+ });
+ match parsed {
+ Ok(h) => hosts.push(h),
+ Err(why) => feed.skipped.push(format!("{entry}: {why}")),
+ }
+ }
+ // A jump host must be one of the feed's own, kept, and not lead round in a circle.
+ let mut circular = Vec::new();
+ {
+ let jumps: HashMap<&str, &str> = hosts
+ .iter()
+ .filter(|(_, j, _)| !j.is_empty())
+ .map(|(id, j, _)| (id.as_str(), j.as_str()))
+ .collect();
+ // In a circle: following its jump hosts comes back to it. (One that only leads into a
+ // circle goes with the hosts it jumps through, below.)
+ for (id, _, _) in &hosts {
+ let mut at = id.as_str();
+ for _ in 0..hosts.len() {
+ match jumps.get(at) {
+ Some(next) if *next == id.as_str() => {
+ circular.push(id.clone());
+ break;
+ }
+ Some(next) => at = next,
+ None => break,
+ }
+ }
+ }
+ }
+ hosts.retain(|(id, _, _)| {
+ let keep = !circular.contains(id);
+ if !keep {
+ feed.skipped
+ .push(format!("{id}: its jump hosts go round in a circle"));
+ }
+ keep
+ });
+ loop {
+ let kept: HashSet = hosts.iter().map(|(id, _, _)| id.clone()).collect();
+ let before = hosts.len();
+ hosts.retain(|(id, jump, _)| {
+ let keep = jump.is_empty() || kept.contains(jump);
+ if !keep {
+ feed.skipped.push(format!(
+ "{id}: its jump host {jump:?} isn't one of the feed's hosts (or was skipped)"
+ ));
+ }
+ keep
+ });
+ if hosts.len() == before {
+ break;
+ }
+ }
+ feed.hosts = hosts
+ .into_iter()
+ .map(|(_, jump, mut h)| {
+ h.jump = (!jump.is_empty()).then(|| format!("feed:{jump}"));
+ h
+ })
+ .collect();
+
+ let mut seen = HashSet::new();
+ for (n, v) in raw_snippets.iter().enumerate() {
+ let entry = entry_name(v, "snippet", n);
+ let parsed = serde_json::from_value::(v.clone())
+ .map_err(|e| e.to_string())
+ .and_then(|raw| {
+ if !valid_id(&raw.id) {
+ return Err("its id isn't 1 to 200 of A-Z, a-z, 0-9 and . _ : / @ -".into());
+ }
+ if !seen.insert(raw.id.clone()) {
+ return Err("another snippet has this id".into());
+ }
+ snippet(raw)
+ });
+ match parsed {
+ Ok(s) => feed.snippets.push(s),
+ Err(why) => feed.skipped.push(format!("{entry}: {why}")),
+ }
+ }
+ Ok(feed)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::json;
+
+ const KEY: &str =
+ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl";
+
+ fn parsed(doc: Value) -> Feed {
+ parse("Ops", doc.to_string().as_bytes()).unwrap()
+ }
+
+ #[test]
+ fn the_roadmap_example_reads() {
+ let feed = parsed(json!({
+ "version": 1,
+ "name": "Vallic operations",
+ "hosts": [
+ { "id": "bastion-fsn1", "label": "Bastion FSN1", "hostname": "bastion-fsn1.example.net",
+ "port": 22, "user": "vallic", "group": "Bastions", "host_keys": [KEY] },
+ { "id": "server-1234", "label": "acme-prod-1", "hostname": "10.0.12.34", "port": 2222,
+ "user": "vallic", "group": "Acme", "tags": ["production", "fsn1"], "jump": "bastion-fsn1",
+ "key": "vallic-customers", "host_keys": [format!("{KEY} root@bastion")], "state": "running" }
+ ],
+ "snippets": [
+ { "id": "php-errors", "name": "PHP error log", "body": "podman logs --tail 200 php", "tags": ["diagnostics"] }
+ ]
+ }));
+ assert_eq!(feed.name.as_deref(), Some("Vallic operations"));
+ assert!(feed.skipped.is_empty(), "{:?}", feed.skipped);
+ let [bastion, server] = &feed.hosts[..] else {
+ panic!("two hosts: {:?}", feed.hosts)
+ };
+ assert_eq!(bastion.external_id, "feed:bastion-fsn1");
+ assert_eq!(bastion.jump, None);
+ assert_eq!(server.jump.as_deref(), Some("feed:bastion-fsn1"));
+ assert_eq!(
+ (
+ server.hostname.as_str(),
+ server.port,
+ server.user.as_str(),
+ server.group.as_str()
+ ),
+ ("10.0.12.34", 2222, "vallic", "Acme")
+ );
+ assert_eq!(server.key_name.as_deref(), Some("vallic-customers"));
+ // The comment is dropped; "running" is no state to show.
+ assert_eq!(server.host_keys, [KEY]);
+ assert_eq!(server.state, None);
+ assert_eq!(server.tags, ["production", "fsn1"]);
+ assert!(server.login_command.is_none() && server.via.is_none());
+ assert_eq!(
+ feed.snippets,
+ [FeedSnippet {
+ external_id: "feed:php-errors".into(),
+ name: "PHP error log".into(),
+ body: "podman logs --tail 200 php".into(),
+ tags: vec!["diagnostics".into()],
+ }]
+ );
+ }
+
+ #[test]
+ fn defaults_and_optional_fields() {
+ let feed = parsed(json!({
+ "version": 1,
+ "hosts": [{ "id": "web", "hostname": "web.example", "user": "deploy", "group": null,
+ "tags": null, "state": "stopped" }]
+ }));
+ let h = &feed.hosts[0];
+ assert_eq!(
+ (h.label.as_str(), h.port, h.group.as_str()),
+ ("web", 22, "")
+ );
+ assert_eq!(h.state.as_deref(), Some("stopped"));
+ assert!(h.host_keys.is_empty() && h.key_name.is_none());
+ assert!(feed.name.is_none() && feed.snippets.is_empty());
+ }
+
+ #[test]
+ fn invalid_entries_are_skipped_and_said() {
+ let feed = parsed(json!({
+ "version": 1,
+ "hosts": [
+ { "id": "ok", "hostname": "ok.example", "user": "u" },
+ { "id": "ok", "hostname": "again.example", "user": "u" },
+ { "id": "bad id!", "hostname": "x", "user": "u" },
+ { "id": "dash", "hostname": "-oProxyCommand=x", "user": "u" },
+ { "id": "space", "hostname": "a b", "user": "u" },
+ { "id": "nouser", "hostname": "x.example" },
+ { "id": "port", "hostname": "x.example", "user": "u", "port": 70000 },
+ { "id": "key", "hostname": "x.example", "user": "u", "host_keys": ["ssh-dss AAAA"] },
+ { "id": "notbase64", "hostname": "x.example", "user": "u", "host_keys": ["ssh-ed25519 $(id)"] },
+ { "id": "orphan", "hostname": "x.example", "user": "u", "jump": "nowhere" },
+ { "id": "a", "hostname": "a.example", "user": "u", "jump": "b" },
+ { "id": "b", "hostname": "b.example", "user": "u", "jump": "a" },
+ { "id": "behind-a", "hostname": "c.example", "user": "u", "jump": "a" },
+ { "id": "self", "hostname": "s.example", "user": "u", "jump": "self" },
+ "not an object"
+ ],
+ "snippets": [
+ { "id": "ok", "name": "Uptime", "body": "uptime" },
+ { "id": "esc", "name": "Sneaky", "body": "echo \u{1b}[201~" },
+ { "id": "empty", "name": "Nothing", "body": " " },
+ { "id": "big", "name": "Big", "body": "x".repeat(MAX_BODY + 1) }
+ ]
+ }));
+ assert_eq!(feed.hosts.len(), 1, "{:?}", feed.hosts);
+ assert_eq!(feed.snippets.len(), 1);
+ let said = feed.skipped.join("\n");
+ for expected in [
+ "ok: another host has this id",
+ "host 3: its id isn't",
+ "dash: the host name \"-oProxyCommand=x\" isn't one SSH can use",
+ "space: the host name",
+ "nouser: missing field `user`",
+ "port: the port isn't 1 to 65535",
+ "key: the host key \"ssh-dss AAAA\" isn't an OpenSSH public key",
+ "notbase64: the host key",
+ "orphan: its jump host \"nowhere\" isn't one of the feed's hosts",
+ "a: its jump hosts go round in a circle",
+ "b: its jump hosts go round in a circle",
+ "behind-a: its jump host \"a\" isn't one of the feed's hosts (or was skipped)",
+ "self: it can't be its own jump host",
+ "host 15: invalid type",
+ "esc: the body has control characters",
+ "empty: no body",
+ "big: the body is over 64 KiB",
+ ] {
+ assert!(said.contains(expected), "{expected:?} in:\n{said}");
+ }
+ }
+
+ #[test]
+ fn a_bad_document_is_refused_whole() {
+ let err = |doc: &str| parse("Ops", doc.as_bytes()).unwrap_err().to_string();
+ assert!(err("").starts_with("Ops sent something this version can't read: "));
+ assert!(err("[]").ends_with("not a JSON object"));
+ assert!(err(r#"{"hosts": []}"#).ends_with("no \"version\": 1"));
+ assert_eq!(
+ err(r#"{"version": 2}"#),
+ "Ops is a version 2 feed: update MorphGate to read it"
+ );
+ assert!(err(r#"{"version": 1, "hosts": {}}"#).ends_with("\"hosts\" isn't a list"));
+ let many = json!({ "version": 1, "hosts": vec![json!({}); MAX_HOSTS + 1] });
+ assert_eq!(
+ err(&many.to_string()),
+ "Ops lists 5001 hosts: a feed holds at most 5,000"
+ );
+ }
+
+ #[test]
+ fn only_https_or_this_computer() {
+ assert!(check_url("https://ops.example/feed").is_ok());
+ assert!(check_url("http://127.0.0.1:8080/feed").is_ok());
+ assert!(check_url("http://localhost/feed").is_ok());
+ assert!(check_url("http://ops.example/feed").is_err());
+ assert!(check_url("file:///etc/passwd").is_err());
+ }
+}
diff --git a/crates/mg-inventory/src/lib.rs b/crates/mg-inventory/src/lib.rs
index c0e65dc..9a37ea2 100644
--- a/crates/mg-inventory/src/lib.rs
+++ b/crates/mg-inventory/src/lib.rs
@@ -3,12 +3,14 @@
//! never reach MorphGate's server.
//!
//! One module per provider (docs/features/cloud-sources.md): Vallic Cloud through its API; AWS,
-//! Google Cloud, Azure, Hetzner Cloud and DigitalOcean through their CLIs, as [`Machine`]s.
+//! Google Cloud, Azure, Hetzner Cloud and DigitalOcean through their CLIs, as [`Machine`]s; and
+//! feeds, documents a team publishes at a URL.
pub mod aws;
pub mod azure;
pub mod cli;
pub mod digitalocean;
+pub mod feed;
pub mod gcp;
pub mod hetzner;
pub mod shape;
@@ -49,6 +51,11 @@ pub struct InventoryHost {
/// Reached through its cloud (Session Manager, an Instance Connect Endpoint, IAP) rather
/// than an address; `hostname` is then the instance's id or name.
pub via: Option,
+ /// The server's host keys (`ssh-ed25519 AAAA…`), as its publisher knows them (feeds):
+ /// trusted for it without asking.
+ pub host_keys: Vec,
+ /// Tags it comes with (feeds), added to the person's.
+ pub tags: Vec,
}
/// How a machine is reached through its cloud: what the command needs (the app builds it).
@@ -131,6 +138,9 @@ pub enum Error {
SignedOut(String),
#[error("{0}")]
Api(String),
+ /// A feed answered 401 or 403 to its token.
+ #[error("{0} refused the token")]
+ TokenRefused(String),
/// A provider's CLI isn't on this device.
#[error(
"{0} isn't installed on this computer, or MorphGate can't find it: install it, or give its path in the source"
diff --git a/crates/mg-inventory/src/shape.rs b/crates/mg-inventory/src/shape.rs
index 6e426ca..f9e57ae 100644
--- a/crates/mg-inventory/src/shape.rs
+++ b/crates/mg-inventory/src/shape.rs
@@ -117,6 +117,8 @@ pub fn hosts(provider: &str, machines: &[Machine], shape: &Shape) -> Shaped {
state: (!m.running).then(|| m.state.clone()),
key_name: m.key_name.clone(),
via: through,
+ host_keys: Vec::new(),
+ tags: Vec::new(),
});
}
out
diff --git a/crates/mg-inventory/src/vallic.rs b/crates/mg-inventory/src/vallic.rs
index 98ef567..8b2e89e 100644
--- a/crates/mg-inventory/src/vallic.rs
+++ b/crates/mg-inventory/src/vallic.rs
@@ -527,6 +527,8 @@ pub fn hosts_of(project: &str, env: &Environment, t: &SshTarget) -> Vec (String, Arc>>) {
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let addr = listener.local_addr().unwrap();
+ let heads = Arc::new(Mutex::new(Vec::new()));
+ let seen = heads.clone();
+ tokio::spawn(async move {
+ loop {
+ let (mut sock, _) = listener.accept().await.unwrap();
+ let seen = seen.clone();
+ tokio::spawn(async move {
+ let mut buf = Vec::new();
+ let mut chunk = [0u8; 4096];
+ let head = loop {
+ let n = sock.read(&mut chunk).await.unwrap();
+ if n == 0 {
+ return;
+ }
+ buf.extend_from_slice(&chunk[..n]);
+ if let Some(end) = buf.windows(4).position(|w| w == b"\r\n\r\n") {
+ break String::from_utf8_lossy(&buf[..end]).to_string();
+ }
+ };
+ seen.lock().unwrap().push(head.clone());
+ let path = head.split_whitespace().nth(1).unwrap_or("").to_owned();
+ let authorized = head
+ .lines()
+ .any(|l| l.eq_ignore_ascii_case("authorization: Bearer T1"));
+ let (status, extra, body) = match path.as_str() {
+ _ if !authorized => ("401 Unauthorized", String::new(), "{}".to_owned()),
+ "/feed" => (
+ "200 OK",
+ String::new(),
+ serde_json::json!({
+ "version": 1,
+ "name": "Ops",
+ "hosts": [{ "id": "web-1", "hostname": "10.0.0.1", "user": "ops" }],
+ "snippets": [{ "id": "up", "name": "Uptime", "body": "uptime" }]
+ })
+ .to_string(),
+ ),
+ "/big" => (
+ "200 OK",
+ String::new(),
+ format!(
+ "{{\"version\": 1, \"name\": \"{}\"}}",
+ "x".repeat(feed::MAX_BYTES)
+ ),
+ ),
+ "/moved" => (
+ "302 Found",
+ "location: http://example.com/feed\r\n".to_owned(),
+ String::new(),
+ ),
+ _ => ("404 Not Found", String::new(), String::new()),
+ };
+ let reply = format!(
+ "HTTP/1.1 {status}\r\ncontent-type: application/json\r\n{extra}content-length: {}\r\nconnection: close\r\n\r\n{body}",
+ body.len()
+ );
+ let _ = sock.write_all(reply.as_bytes()).await;
+ });
+ }
+ });
+ (format!("http://{addr}"), heads)
+}
+
+#[tokio::test]
+async fn reads_a_feed_with_its_token() {
+ let (base, heads) = publisher().await;
+ let feed = feed::fetch("Ops", &format!("{base}/feed"), "T1")
+ .await
+ .unwrap();
+ assert_eq!(feed.name.as_deref(), Some("Ops"));
+ assert_eq!(feed.hosts[0].external_id, "feed:web-1");
+ assert_eq!(feed.snippets[0].body, "uptime");
+ let head = heads.lock().unwrap()[0].to_lowercase();
+ assert!(head.contains("accept: application/json"), "{head}");
+ assert!(
+ head.contains(&format!(
+ "user-agent: morphgate/{}",
+ env!("CARGO_PKG_VERSION")
+ )),
+ "{head}"
+ );
+}
+
+#[tokio::test]
+async fn a_refused_token_too_large_and_elsewhere_are_said() {
+ let (base, _) = publisher().await;
+ let err = |r: mg_inventory::Result| r.unwrap_err().to_string();
+ assert_eq!(
+ err(feed::fetch("Ops", &format!("{base}/feed"), "wrong").await),
+ "Ops refused the token"
+ );
+ assert_eq!(
+ err(feed::fetch("Ops", &format!("{base}/big"), "T1").await),
+ "Ops sent more than 5 MiB: a feed is at most that"
+ );
+ // Not followed to plain HTTP on another host: the redirect itself is the answer.
+ assert_eq!(
+ err(feed::fetch("Ops", &format!("{base}/moved"), "T1").await),
+ "Ops answered 302 Found"
+ );
+ assert_eq!(
+ err(feed::fetch("Ops", &format!("{base}/nothing"), "T1").await),
+ "Ops answered 404 Not Found"
+ );
+}
diff --git a/crates/mg-ssh/src/connection.rs b/crates/mg-ssh/src/connection.rs
index 686986e..6f9405e 100644
--- a/crates/mg-ssh/src/connection.rs
+++ b/crates/mg-ssh/src/connection.rs
@@ -53,6 +53,30 @@ pub struct Hop {
/// TCP connection. `%h`, `%p` and `%r` are this hop's host, port and user. For bastions that
/// refuse forwarding but hand a session on (`nc %h %p`, Vallic's ` jump `).
pub via_command: Option,
+ /// The host keys whoever described this host lists for it (a feed): the server must
+ /// present one of them, and is trusted without asking.
+ pub listed_keys: Option,
+}
+
+/// Host keys a server is known to have, from where its host came from.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub struct ListedKeys {
+ /// OpenSSH public keys, `ssh-ed25519 AAAA…`.
+ pub keys: Vec,
+ /// Who lists them, for messages (the feed's name).
+ pub from: String,
+}
+
+/// [`ListedKeys`], read: `None` when none of them is a key (then the host is asked about as
+/// any other).
+fn listed(keys: Option) -> Option<(Vec, String)> {
+ let l = keys?;
+ let parsed: Vec = l
+ .keys
+ .iter()
+ .filter_map(|k| PublicKey::from_openssh(k.trim()).ok())
+ .collect();
+ (!parsed.is_empty()).then_some((parsed, l.from))
}
/// Asks the user whether to trust a host key seen for the first time.
@@ -273,6 +297,7 @@ async fn connect_hop(
let verifier = Verifier {
host: hop.host.clone(),
port: hop.port,
+ listed: listed(hop.listed_keys.clone()),
known_hosts: known_hosts.clone(),
prompt: prompt.clone(),
rejected: rejected.clone(),
@@ -550,6 +575,8 @@ async fn agent(
pub(crate) struct Verifier {
host: String,
port: u16,
+ /// The keys listed for this host, and by whom: they decide instead of known_hosts.
+ listed: Option<(Vec, String)>,
known_hosts: Arc,
prompt: Arc,
/// Why the key was refused, surfaced after `connect` fails.
@@ -610,6 +637,29 @@ impl client::Handler for Verifier {
}
};
let mut info = HostKeyInfo::new(&self.host, self.port, &key);
+ // Listed for the host (its feed): one of those, or refused. A revoked key still isn't.
+ if let Some((keys, from)) = &self.listed {
+ if let Some(revoked) = self
+ .known_hosts
+ .revoked(&self.host, self.port, key.key_data())
+ {
+ return Ok(self.reject(Error::HostKeyRevoked {
+ host: self.host.clone(),
+ port: self.port,
+ fingerprint: info.fingerprint.clone(),
+ from: revoked,
+ }));
+ }
+ if keys.iter().any(|k| k.key_data() == key.key_data()) {
+ return Ok(true);
+ }
+ return Ok(self.reject(Error::HostKeyNotListed {
+ host: self.host.clone(),
+ port: self.port,
+ fingerprint: info.fingerprint.clone(),
+ from: from.clone(),
+ }));
+ }
match self.known_hosts.check(&self.host, self.port, &key)? {
HostKeyCheck::Trusted => Ok(true),
HostKeyCheck::Revoked { from } => Ok(self.reject(Error::HostKeyRevoked {
diff --git a/crates/mg-ssh/src/error.rs b/crates/mg-ssh/src/error.rs
index 8561859..381e6d8 100644
--- a/crates/mg-ssh/src/error.rs
+++ b/crates/mg-ssh/src/error.rs
@@ -26,6 +26,16 @@ pub enum Error {
fingerprint: String,
from: String,
},
+ /// A key other than the ones listed for the host (by its feed).
+ #[error(
+ "the host key of {host}:{port} ({fingerprint}) isn't one {from} lists for it: refused. Someone may be intercepting the connection, or {from} doesn't know the server's new key yet"
+ )]
+ HostKeyNotListed {
+ host: String,
+ port: u16,
+ fingerprint: String,
+ from: String,
+ },
/// A host certificate where a CA is trusted for the host, that the CA didn't sign for it.
#[error("the host certificate of {host}:{port} isn't accepted: {why}")]
HostCertificate {
diff --git a/crates/mg-ssh/src/lib.rs b/crates/mg-ssh/src/lib.rs
index 1dbd8a2..d234f5f 100644
--- a/crates/mg-ssh/src/lib.rs
+++ b/crates/mg-ssh/src/lib.rs
@@ -17,7 +17,9 @@ pub mod sftp;
pub mod ssh_config;
pub use agent::{AgentKey, AgentSource};
-pub use connection::{AsyncStream, Auth, BoxedStream, Connection, Dialer, Hop, HostKeyPrompt};
+pub use connection::{
+ AsyncStream, Auth, BoxedStream, Connection, Dialer, Hop, HostKeyPrompt, ListedKeys,
+};
pub use error::Error;
pub use forward::{Forward, Tunnel};
pub use keys::KeyInfo;
diff --git a/crates/mg-ssh/src/session.rs b/crates/mg-ssh/src/session.rs
index bd02246..9c69d18 100644
--- a/crates/mg-ssh/src/session.rs
+++ b/crates/mg-ssh/src/session.rs
@@ -29,6 +29,8 @@ pub struct ConnectOptions {
pub jumps: Vec,
/// How the last jump host carries the target: a command it runs (see [`Hop::via_command`]).
pub via_command: Option,
+ /// The target's host keys, where its feed lists them (see [`Hop::listed_keys`]).
+ pub listed_keys: Option,
/// Opens the first hop's TCP connection (e.g. through a per-host VPN); `None` = the OS.
pub dialer: Option>,
/// Forward this agent to the server (OpenSSH `ForwardAgent`): programs there can sign with
@@ -81,6 +83,7 @@ impl Session {
user: opts.user,
auth: opts.auth,
via_command: opts.via_command,
+ listed_keys: opts.listed_keys,
};
let conn = Connection::open(
target,
diff --git a/crates/mg-ssh/tests/live.rs b/crates/mg-ssh/tests/live.rs
index b9664d3..371c417 100644
--- a/crates/mg-ssh/tests/live.rs
+++ b/crates/mg-ssh/tests/live.rs
@@ -79,6 +79,7 @@ fn opts(env: &Env, auth: Auth) -> ConnectOptions {
connect_timeout: Duration::from_secs(10),
jumps: vec![],
via_command: None,
+ listed_keys: None,
dialer: None,
forward_agent: None,
}
@@ -224,6 +225,87 @@ async fn changed_host_key_fails_closed() {
);
}
+/// Host keys a feed lists for a host decide instead of known_hosts: the server's key connects
+/// without asking (over a stale pin, too) and isn't written down; another one is refused.
+#[tokio::test]
+#[ignore = "needs MG_TEST_SSH"]
+async fn listed_host_keys_decide() {
+ let Some(env) = env() else { return };
+ // The server's key, learned the usual way.
+ let dir = tempfile::tempdir().unwrap();
+ let learned = dir.path().join("learned");
+ let prompt = Arc::new(CountingPrompt {
+ accept: true,
+ ..Default::default()
+ });
+ let (s, ev) = Session::connect(
+ opts(&env, auth(&env)),
+ Arc::new(KnownHosts::new(&learned)),
+ prompt,
+ )
+ .await
+ .unwrap();
+ run(s, ev, "true").await;
+ let line = std::fs::read_to_string(&learned).unwrap();
+ let key: String = line
+ .split_whitespace()
+ .skip(1)
+ .take(2)
+ .collect::>()
+ .join(" ");
+ let listed = |key: &str| {
+ let mut o = opts(&env, auth(&env));
+ o.listed_keys = Some(mg_ssh::ListedKeys {
+ keys: vec![key.to_owned()],
+ from: "Ops".into(),
+ });
+ o
+ };
+
+ let fresh = dir.path().join("fresh");
+ let never = Arc::new(CountingPrompt::default());
+ let (s, ev) = Session::connect(
+ listed(&key),
+ Arc::new(KnownHosts::new(&fresh)),
+ never.clone(),
+ )
+ .await
+ .unwrap();
+ run(s, ev, "true").await;
+ assert!(!fresh.exists(), "a listed key isn't written down");
+
+ let stale = dir.path().join("stale");
+ let entry = if env.port == 22 {
+ env.host.clone()
+ } else {
+ format!("[{}]:{}", env.host, env.port)
+ };
+ let other = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDRbMaf7RI+oSkPOCPrFjPoMWJEIu96Drte4Q3r9OQYR";
+ std::fs::write(&stale, format!("{entry} {other}\n")).unwrap();
+ let (s, ev) = Session::connect(
+ listed(&key),
+ Arc::new(KnownHosts::new(&stale)),
+ never.clone(),
+ )
+ .await
+ .unwrap();
+ run(s, ev, "true").await;
+
+ let err = Session::connect(
+ listed(other),
+ Arc::new(KnownHosts::new(&fresh)),
+ never.clone(),
+ )
+ .await
+ .err()
+ .unwrap();
+ assert!(
+ matches!(&err, Error::HostKeyNotListed { from, .. } if from == "Ops"),
+ "{err}"
+ );
+ assert_eq!(never.calls.load(Ordering::SeqCst), 0, "nothing asked");
+}
+
#[tokio::test]
#[ignore = "needs MG_TEST_SSH"]
async fn wrong_password_fails() {
@@ -314,6 +396,7 @@ fn hop(env: &Env) -> Hop {
user: env.user.clone(),
auth: auth(env),
via_command: None,
+ listed_keys: None,
}
}
diff --git a/crates/mg-store/Cargo.toml b/crates/mg-store/Cargo.toml
index de75455..7a13682 100644
--- a/crates/mg-store/Cargo.toml
+++ b/crates/mg-store/Cargo.toml
@@ -18,6 +18,7 @@ mg-vault.workspace = true
rusqlite = { version = "0.40.2", features = ["bundled"] }
serde.workspace = true
serde_json.workspace = true
+sha2 = "0.11.0"
thiserror.workspace = true
ulid = "3.0.0"
zeroize = "1.9.1"
diff --git a/crates/mg-store/src/error.rs b/crates/mg-store/src/error.rs
index d305bf5..4f03cea 100644
--- a/crates/mg-store/src/error.rs
+++ b/crates/mg-store/src/error.rs
@@ -30,6 +30,22 @@ pub enum Error {
Untrusted(String),
#[error("vault data is corrupt: {0}")]
Corrupt(String),
+ /// A signed copy of an item older than one this device has seen: a server serving an old
+ /// envelope again.
+ #[error(
+ "the sync server sent an older copy of item {item} (version {remote}) than this device already has (version {seen}); refusing to roll it back"
+ )]
+ OlderItem {
+ item: String,
+ seen: i64,
+ remote: i64,
+ },
+ /// With strict item history: a change without a version, or a deletion without a
+ /// signature, to an item that has one.
+ #[error(
+ "the sync server sent a change to item {item} that isn't signed with a version; refusing it"
+ )]
+ UnversionedItem { item: String },
#[error(transparent)]
Crypto(#[from] mg_vault::Error),
#[error("database: {0}")]
diff --git a/crates/mg-store/src/history.rs b/crates/mg-store/src/history.rs
new file mode 100644
index 0000000..7cac598
--- /dev/null
+++ b/crates/mg-store/src/history.rs
@@ -0,0 +1,160 @@
+//! Item history: the version every write carries inside its signed envelope, and signed
+//! deletions, so a server can't serve a device an older copy of an item than one it has seen.
+//!
+//! An item's plaintext is its JSON with an `item_version`: the time of the write in milliseconds,
+//! or one more than the highest version the writer knows of the item when that is larger. A
+//! deletion is an envelope too, `{"type":"deleted","item_version":n}`, sealed for the same item.
+//! Apps before item history (0.2.0) ignore both: they write items without a version and
+//! deletions without an envelope, which a device accepts while such apps may still be in use
+//! (see `LocalVault::set_strict_history`).
+
+use serde::Deserialize;
+use zeroize::Zeroizing;
+
+use crate::vault::now;
+use crate::{Error, ItemData, Result};
+
+/// The plaintext field that holds an item's version.
+const FIELD: &str = "item_version";
+/// The `type` of a deletion's plaintext.
+const DELETED: &str = "deleted";
+
+/// What an envelope says: an item (`data`) or a deletion (`None`), and its version (`None`:
+/// written by an app before item history).
+#[derive(Debug)]
+pub(crate) struct Copy {
+ pub data: Option,
+ pub version: Option,
+}
+
+/// The version of a write after `known`, the highest version the writer has of the item.
+pub(crate) fn next(known: i64) -> i64 {
+ known.saturating_add(1).max(now())
+}
+
+/// `data` as an item's plaintext with `version`.
+pub(crate) fn plaintext(data: &ItemData, version: i64) -> Result>> {
+ let json = Zeroizing::new(serde_json::to_vec(data).map_err(|e| Error::Invalid(e.to_string()))?);
+ // An item is always a JSON object with a `type`: the version goes in before its end.
+ let (end, body) = json
+ .split_last()
+ .filter(|(end, body)| **end == b'}' && body.len() > 1)
+ .ok_or_else(|| Error::Invalid("an item must be a JSON object".into()))?;
+ let field = format!(",\"{FIELD}\":{version}");
+ let mut out = Zeroizing::new(Vec::with_capacity(json.len() + field.len()));
+ out.extend_from_slice(body);
+ out.extend_from_slice(field.as_bytes());
+ out.push(*end);
+ Ok(out)
+}
+
+/// A deletion's plaintext.
+pub(crate) fn deletion(version: i64) -> Vec {
+ format!("{{\"type\":\"{DELETED}\",\"{FIELD}\":{version}}}").into_bytes()
+}
+
+/// `plain` (an item or a deletion, of any kind, from any app) with its version set to `version`.
+pub(crate) fn with_version(plain: &[u8], version: i64) -> Result>> {
+ let mut value: serde_json::Value =
+ serde_json::from_slice(plain).map_err(|e| Error::Corrupt(e.to_string()))?;
+ let object = value
+ .as_object_mut()
+ .ok_or_else(|| Error::Corrupt("an item must be a JSON object".into()))?;
+ object.insert(FIELD.into(), version.into());
+ Ok(Zeroizing::new(
+ serde_json::to_vec(&value).map_err(|e| Error::Invalid(e.to_string()))?,
+ ))
+}
+
+/// Reads an opened envelope of item `id`.
+pub(crate) fn read(id: &str, plain: &[u8]) -> Result {
+ #[derive(Deserialize)]
+ struct Head {
+ #[serde(rename = "type")]
+ kind: String,
+ #[serde(default)]
+ item_version: Option,
+ }
+ let corrupt = |e: serde_json::Error| Error::Corrupt(format!("item {id}: {e}"));
+ let head: Head = serde_json::from_slice(plain).map_err(corrupt)?;
+ // Versions start at 1; anything else counts as none.
+ let version = head.item_version.filter(|v| *v > 0);
+ let data = if head.kind == DELETED {
+ None
+ } else {
+ Some(serde_json::from_slice(plain).map_err(corrupt)?)
+ };
+ Ok(Copy { data, version })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::Snippet;
+
+ fn snippet() -> ItemData {
+ ItemData::Snippet(Snippet {
+ name: "uptime".into(),
+ body: "uptime".into(),
+ tags: vec![],
+ source: None,
+ })
+ }
+
+ #[test]
+ fn items_and_deletions_carry_their_version() {
+ let plain = plaintext(&snippet(), 1_760_000_000_123).unwrap();
+ assert_eq!(
+ std::str::from_utf8(&plain).unwrap(),
+ r#"{"type":"snippet","name":"uptime","body":"uptime","item_version":1760000000123}"#
+ );
+ let copy = read("I", &plain).unwrap();
+ assert_eq!(
+ (copy.data, copy.version),
+ (Some(snippet()), Some(1_760_000_000_123))
+ );
+
+ let gone = deletion(7);
+ assert_eq!(gone, br#"{"type":"deleted","item_version":7}"#);
+ let copy = read("I", &gone).unwrap();
+ assert_eq!((copy.data, copy.version), (None, Some(7)));
+ }
+
+ #[test]
+ fn items_from_older_apps_have_no_version() {
+ let plain = serde_json::to_vec(&snippet()).unwrap();
+ let copy = read("I", &plain).unwrap();
+ assert_eq!((copy.data, copy.version), (Some(snippet()), None));
+ // Not a version: zero or below.
+ let copy = read(
+ "I",
+ br#"{"type":"snippet","name":"a","body":"b","item_version":0}"#,
+ )
+ .unwrap();
+ assert_eq!(copy.version, None);
+ }
+
+ #[test]
+ fn a_new_version_keeps_what_this_app_doesnt_know() {
+ let plain = br#"{"type":"future_kind","shape":{"x":1},"item_version":3}"#;
+ let copy = read("I", plain).unwrap();
+ assert_eq!(
+ (copy.data, copy.version),
+ (Some(ItemData::Unknown), Some(3))
+ );
+ let again: serde_json::Value =
+ serde_json::from_slice(&with_version(plain, 9).unwrap()).unwrap();
+ assert_eq!(
+ again,
+ serde_json::json!({"type": "future_kind", "shape": {"x": 1}, "item_version": 9})
+ );
+ }
+
+ #[test]
+ fn versions_only_grow() {
+ let far_ahead = now() + 86_400_000;
+ assert_eq!(next(far_ahead), far_ahead + 1);
+ assert!(next(0) >= now() - 1000);
+ assert_eq!(next(i64::MAX), i64::MAX);
+ }
+}
diff --git a/crates/mg-store/src/lib.rs b/crates/mg-store/src/lib.rs
index 126ae57..2176f7c 100644
--- a/crates/mg-store/src/lib.rs
+++ b/crates/mg-store/src/lib.rs
@@ -10,6 +10,7 @@ mod blobs;
mod device;
mod error;
pub mod export;
+mod history;
pub mod model;
pub mod moving;
mod schema;
@@ -26,9 +27,12 @@ pub use mg_vault::BlobKind;
pub use model::{
Attachment, CertRenewal, CloudReach, CloudSource, ForwardRule, Host, HostAuth, HostCa,
HostGroup, HostSource, Identity, Item, ItemData, LogChunk, MAX_ATTACHMENT_BYTES, MemberKey,
- PortForward, SessionLog, Snippet, SshKey, TerminalTheme, VpnKind, VpnProfile, is_hex_color,
+ PortForward, SessionLog, Snippet, SnippetSource, SshKey, TerminalTheme, VpnKind, VpnProfile,
+ is_hex_color,
};
pub use moving::{Carry, MovePlan, Moved, Planned, Why};
-pub use vault::{Access, LocalVault, MIN_PASSWORD_CHARS, NewCredentials, Status, VaultInfo};
+pub use vault::{
+ Access, LocalVault, MIN_PASSWORD_CHARS, NewCredentials, Status, VaultInfo, derived_id,
+};
pub type Result = core::result::Result;
diff --git a/crates/mg-store/src/model.rs b/crates/mg-store/src/model.rs
index aefe6ee..d42838a 100644
--- a/crates/mg-store/src/model.rs
+++ b/crates/mg-store/src/model.rs
@@ -202,6 +202,23 @@ pub struct HostSource {
/// How it's reached when its source goes through the cloud rather than an address.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reach: Option,
+ /// The server's host keys as its feed gives them (`ssh-ed25519 AAAA…`): trusted when
+ /// connecting to this host, without asking; a server with another key is refused.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub host_keys: Vec,
+ /// The tags its feed gave it, among the host's: a refresh replaces these and keeps the
+ /// person's.
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub tags: Vec,
+}
+
+/// Where a snippet a feed keeps came from: the feed decides it (it's read-only in the app).
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct SnippetSource {
+ /// The `cloud_source` item, in the same vault.
+ pub source_ref: String,
+ /// `feed:`: what a refresh matches on.
+ pub external_id: String,
}
/// A machine reached through its cloud: what the command needs, never the command itself (a vault
@@ -260,12 +277,14 @@ impl CloudReach {
}
/// Hosts kept in step with a cloud provider (docs/features/cloud-sources.md): Vallic Cloud, AWS, Google
-/// Cloud, Azure, Hetzner Cloud or DigitalOcean. No credentials: each device signs in to Vallic itself
-/// and keeps that on the device; the others are listed with their CLI as the device has it set up.
+/// Cloud, Azure, Hetzner Cloud or DigitalOcean, or a feed. No cloud credentials: each device signs in
+/// to Vallic itself and keeps that on the device; the others are listed with their CLI as the device
+/// has it set up. A feed's token is the exception: it only reads a host list, and every device that
+/// refreshes needs it.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct CloudSource {
pub label: String,
- /// `vallic`, `aws`, `gcp`, `azure`, `hetzner` or `digitalocean`.
+ /// `vallic`, `aws`, `gcp`, `azure`, `hetzner`, `digitalocean` or `feed`.
pub provider: String,
/// The provider's address, when not its usual one.
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -301,6 +320,23 @@ pub struct CloudSource {
/// resource group.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group_tag: Option,
+ /// A feed's address (`https://`, or `http://` on the device itself).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub url: Option,
+ /// The token a feed is read with (`Authorization: Bearer`).
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub token: Option,
+}
+
+impl CloudSource {
+ /// The same source, but for when it was last refreshed: what two devices refreshing at once
+ /// differ in.
+ pub fn same_but_refreshed(&self, other: &CloudSource) -> bool {
+ CloudSource {
+ refreshed_at: other.refreshed_at,
+ ..self.clone()
+ } == *other
+ }
}
impl Host {
@@ -440,6 +476,9 @@ pub struct Snippet {
pub body: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec,
+ /// The feed that keeps it, if one does.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub source: Option,
}
/// A saved tunnel through `host_ref`, started on demand.
diff --git a/crates/mg-store/src/moving.rs b/crates/mg-store/src/moving.rs
index 3a5d92a..4cf7af5 100644
--- a/crates/mg-store/src/moving.rs
+++ b/crates/mg-store/src/moving.rs
@@ -198,6 +198,16 @@ fn relink(
auth: auth(i.auth)?,
..i
}),
+ // Kept by its feed where that goes too; else an ordinary snippet there.
+ ItemData::Snippet(sn) => ItemData::Snippet(crate::Snippet {
+ source: sn.source.and_then(|src| {
+ map.get(&src.source_ref).map(|to| crate::SnippetSource {
+ source_ref: to.clone(),
+ ..src
+ })
+ }),
+ ..sn
+ }),
ItemData::HostGroup(g) => ItemData::HostGroup(crate::HostGroup {
identity_ref: optional(g.identity_ref)?,
jump_ref: optional(g.jump_ref)?,
@@ -311,11 +321,7 @@ impl LocalVault {
let done = (|| -> Result {
let stamp = now();
for p in going.iter().filter(|p| p.carry == Carry::Move) {
- self.conn.execute(
- "UPDATE items SET envelope = NULL, deleted = 1, dirty = 1, updated_at = ?3
- WHERE vault_id = ?1 AND id = ?2",
- rusqlite::params![from, p.id, stamp],
- )?;
+ self.tombstone(&from, &p.id, stamp)?;
}
let mut forget = Vec::new();
for (id, data, old_blob) in &mut writes {
diff --git a/crates/mg-store/src/schema.rs b/crates/mg-store/src/schema.rs
index 3015600..0815d03 100644
--- a/crates/mg-store/src/schema.rs
+++ b/crates/mg-store/src/schema.rs
@@ -145,6 +145,14 @@ const MIGRATIONS: &[&str] = &[
",
// v7: where to get the plan a refused blob needs.
"ALTER TABLE blobs ADD COLUMN upgrade_url TEXT;",
+ // v8: item history. `version`: the one in the item's envelope (0: none, from an app before
+ // item history). `seen_version`: the one of the last copy the server confirmed, pulled or
+ // pushed (0: it had none). NULL in both: not read yet, from before this migration. A
+ // deletion made on this device keeps its signed envelope in `envelope`.
+ "
+ ALTER TABLE items ADD COLUMN version INTEGER;
+ ALTER TABLE items ADD COLUMN seen_version INTEGER;
+ ",
];
pub(crate) fn migrate(conn: &mut Connection) -> Result<()> {
diff --git a/crates/mg-store/src/sharing.rs b/crates/mg-store/src/sharing.rs
index bf16893..921207f 100644
--- a/crates/mg-store/src/sharing.rs
+++ b/crates/mg-store/src/sharing.rs
@@ -824,6 +824,7 @@ pub async fn refresh_with(
let mut known = HashSet::new();
let mut approving = Vec::new();
let mut keys_required = Vec::new();
+ let mut totp_required = Vec::new();
for m in &memberships {
known.insert(m.id.clone());
let mut snap = match last.remove(&m.id).filter(|s| m.rev == Some(s.rev)) {
@@ -849,6 +850,9 @@ pub async fn refresh_with(
if m.status == MemberStatus::Confirmed && org.policies.require_member_keys == Some(true) {
keys_required.push(m);
}
+ if m.status == MemberStatus::Confirmed && org.policies.require_totp {
+ totp_required.push(m.id.clone());
+ }
{
let v = lock(vault)?;
let root = match v.org_root(&m.id)? {
@@ -912,6 +916,24 @@ pub async fn refresh_with(
let vaults = client.list_vaults().await?;
report.heads = vaults.iter().map(|v| (v.id.clone(), v.head_rev)).collect();
+ // An organization that requires two-step verification leaves its vaults out of the list for
+ // an account without it, without saying why. When none of its vaults is listed (it may have
+ // none for this account yet), the account says whether that's the reason.
+ let totp_unlisted: Vec = totp_required
+ .into_iter()
+ .filter(|org| !vaults.iter().any(|v| v.org_id.as_ref() == Some(org)))
+ .collect();
+ if !totp_unlisted.is_empty() {
+ match client.account().await {
+ Ok(account) if !account.totp_enabled => {
+ for org in totp_unlisted {
+ note_held(&mut report.held, org, Hold::Totp);
+ }
+ }
+ Ok(_) => {}
+ Err(e) => report.errors.push(format!("two-step verification: {e}")),
+ }
+ }
// Former members this device verified: their grants and vault names stay valid.
let pins = lock(vault)?.pinned()?;
let mut keep = HashSet::new();
@@ -962,7 +984,7 @@ pub async fn refresh_with(
note_held(&mut report.held, org, why);
keep.insert(id);
}
- // Listed late, held back by two-step verification, or unknown: keep it.
+ // Listed late, held back by a server that doesn't say why, or unknown: keep it.
_ => {
keep.insert(id);
}
diff --git a/crates/mg-store/src/sync.rs b/crates/mg-store/src/sync.rs
index 933ead1..1b499d6 100644
--- a/crates/mg-store/src/sync.rs
+++ b/crates/mg-store/src/sync.rs
@@ -17,8 +17,10 @@ use rusqlite::{OptionalExtension, params};
use ulid::Ulid;
use zeroize::Zeroizing;
-use crate::vault::{Access, Unlocked, context, decrypt, envelope_key_id, now};
-use crate::{BlobKind, Error, ItemData, LocalVault, Result};
+use crate::vault::{
+ Access, Unlocked, context, decrypt, envelope_key_id, now, open_envelope, version_in,
+};
+use crate::{BlobKind, Error, ItemData, LocalVault, Result, history};
/// Pseudo item id under which the refresh token is sealed (never a valid ULID, so no clash).
const TOKEN_ID: &str = "sync:refresh-token";
@@ -90,9 +92,12 @@ pub struct Outgoing {
pub id: String,
pub base_rev: i64,
pub deleted: bool,
+ /// The item, or a deletion's signed envelope (`None`: a deletion from before item history).
pub envelope: Option>,
/// Local `updated_at` when read; an edit during the push keeps the row dirty.
pub updated_at: i64,
+ /// The version in `envelope` (`None`: not known).
+ pub version: Option,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@@ -138,6 +143,8 @@ pub enum RemoteError {
/// and sync once it's settled.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Hold {
+ /// Turn on two-step verification for the account: the organization requires it.
+ Totp,
/// Sign in at the organization's identity provider.
Sso,
/// An owner or admin, or one of the member's approved devices, has to approve this device.
@@ -609,7 +616,7 @@ impl LocalVault {
[vault],
)?;
let mut stmt = self.conn.prepare(
- "SELECT id, rev, deleted, envelope, updated_at FROM items
+ "SELECT id, rev, deleted, envelope, updated_at, version FROM items
WHERE vault_id = ?1 AND dirty = 1 ORDER BY updated_at, id LIMIT ?2",
)?;
let rows = stmt.query_map(params![vault, (limit + skip.len()) as i64], |r| {
@@ -619,6 +626,7 @@ impl LocalVault {
deleted: r.get(2)?,
envelope: r.get(3)?,
updated_at: r.get(4)?,
+ version: r.get(5)?,
})
})?;
let mut out = Vec::new();
@@ -635,26 +643,47 @@ impl LocalVault {
}
/// Applies server items in one transaction; every envelope is verified (author and vault
- /// key) before anything is written. Returns (items applied, conflicts). `cursor` advances
- /// `head_rev` when given.
+ /// key) before anything is written, and no item goes back to an older version than this
+ /// device has seen of it. Returns (items applied, conflicts). `cursor` advances `head_rev`
+ /// when given.
fn apply_remote(
&mut self,
vault: &str,
items: &[RemoteItem],
cursor: Option,
) -> Result<(usize, usize)> {
+ let strict = self.strict_history;
let keys = self.keys.as_ref().ok_or(Error::Locked)?;
- for r in items.iter().filter(|r| !r.deleted) {
- let env = r
- .envelope
- .as_deref()
- .ok_or_else(|| Error::Corrupt(format!("item {} has no envelope", r.id)))?;
- decrypt(keys, vault, &r.id, env, r.author_id.as_deref())?;
+ let mut copies = Vec::with_capacity(items.len());
+ for r in items {
+ let copy = match (&r.envelope, r.deleted) {
+ (Some(env), false) => {
+ let plain = open_envelope(keys, vault, &r.id, env, r.author_id.as_deref())?;
+ history::read(&r.id, &plain)?
+ }
+ (None, false) => {
+ return Err(Error::Corrupt(format!("item {} has no envelope", r.id)));
+ }
+ // A deletion is signed when it carries its author's deletion envelope; anything
+ // else counts as unsigned (as from an app before item history).
+ (Some(env), true) => open_envelope(keys, vault, &r.id, env, r.author_id.as_deref())
+ .and_then(|plain| history::read(&r.id, &plain))
+ .ok()
+ .filter(|copy| copy.data.is_none())
+ .unwrap_or(history::Copy {
+ data: None,
+ version: None,
+ }),
+ (None, true) => history::Copy {
+ data: None,
+ version: None,
+ },
+ };
// Items are looked up by id: a shared vault must not bring in a new one that would
// stand in for a live item of the personal vault. Moving between vaults keeps ids (the
// old copy becomes a tombstone, and the personal vault syncs first in every round),
// and an id this vault already had here is no newcomer (an item moved back and forth).
- if vault != keys.vault_id {
+ if copy.data.is_some() && vault != keys.vault_id {
let clash = self
.conn
.query_row(
@@ -671,68 +700,103 @@ impl LocalVault {
)));
}
}
+ copies.push(copy);
}
let (mut applied, mut conflicts) = (0, 0);
let stamp = now();
let tx = self.conn.transaction()?;
- for r in items {
+ for (r, copy) in items.iter().zip(copies) {
+ let deleted = copy.data.is_none();
let local: Option = tx
.query_row(
- "SELECT rev, dirty, deleted, envelope, author_id FROM items WHERE vault_id = ?1 AND id = ?2",
+ "SELECT rev, dirty, deleted, envelope, author_id, seen_version FROM items WHERE vault_id = ?1 AND id = ?2",
params![vault, r.id],
- |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?)),
+ |row| {
+ Ok((
+ row.get(0)?,
+ row.get(1)?,
+ row.get(2)?,
+ row.get(3)?,
+ row.get(4)?,
+ row.get(5)?,
+ ))
+ },
)
.optional()?;
- match local {
- None if r.deleted => continue,
- Some((rev, ..)) if rev >= r.rev => continue,
- Some((_, true, local_deleted, local_env, local_author)) => {
- let local_data = match (local_deleted, local_env) {
- (false, Some(env)) => {
- Some(decrypt(keys, vault, &r.id, &env, local_author.as_deref())?)
- }
- _ => None,
- };
- let remote_data = match (&r.deleted, &r.envelope) {
- (false, Some(env)) => {
- Some(decrypt(keys, vault, &r.id, env, r.author_id.as_deref())?)
- }
- _ => None,
- };
- // The same content on both sides (say, both re-sealed it): nothing lost.
- let same = local_data == remote_data;
- if !same {
- conflicts += 1;
+ match &local {
+ None if deleted => continue,
+ Some((rev, ..)) if *rev >= r.rev => continue,
+ _ => {}
+ }
+ if let Some((_, dirty, _, local_env, local_author, seen)) = &local {
+ // The version of the last copy the server confirmed; for a copy from before
+ // item history, the one its envelope holds (unless it has a change on top).
+ let seen = match (seen, dirty, local_env) {
+ (Some(seen), ..) => *seen,
+ (None, false, Some(env)) => {
+ version_in(keys, vault, &r.id, env, local_author.as_deref())
}
- // Local edit lost against the server: keep it as a separate item.
- if let (false, Some(mut data)) = (same, local_data) {
- mark_conflict(&mut data);
- let copy_id = Ulid::generate().to_string();
- let plain = Zeroizing::new(
- serde_json::to_vec(&data).map_err(|e| Error::Invalid(e.to_string()))?,
- );
- let copy = envelope::seal(
- keys.current(vault)?,
- &keys.account,
- &context(vault, ©_id),
- &plain,
- )?;
- tx.execute(
- "INSERT INTO items (id, vault_id, envelope, deleted, rev, dirty, updated_at)
- VALUES (?1, ?2, ?3, 0, 0, 1, ?4)",
- params![copy_id, vault, copy, stamp],
- )?;
+ _ => 0,
+ };
+ match copy.version {
+ Some(v) if v < seen => {
+ return Err(Error::OlderItem {
+ item: r.id.clone(),
+ seen,
+ remote: v,
+ });
+ }
+ None if strict && seen > 0 => {
+ return Err(Error::UnversionedItem { item: r.id.clone() });
}
+ _ => {}
}
- _ => {}
}
+ if let Some((_, true, local_deleted, local_env, local_author, _)) = local {
+ let local_data = match (local_deleted, local_env) {
+ (false, Some(env)) => {
+ Some(decrypt(keys, vault, &r.id, &env, local_author.as_deref())?)
+ }
+ _ => None,
+ };
+ // The same content on both sides (say, both re-sealed it), or what a cloud
+ // source decides on each (two refreshes): nothing of the person's lost.
+ let same = local_data == copy.data
+ || source_decides(local_data.as_ref(), copy.data.as_ref());
+ if !same {
+ conflicts += 1;
+ }
+ // Local edit lost against the server: keep it as a separate item.
+ if let (false, Some(mut data)) = (same, local_data) {
+ mark_conflict(&mut data);
+ let copy_id = Ulid::generate().to_string();
+ let version = history::next(0);
+ let plain = history::plaintext(&data, version)?;
+ let sealed = envelope::seal(
+ keys.current(vault)?,
+ &keys.account,
+ &context(vault, ©_id),
+ &plain,
+ )?;
+ tx.execute(
+ "INSERT INTO items (id, vault_id, envelope, deleted, rev, dirty, updated_at, version, seen_version)
+ VALUES (?1, ?2, ?3, 0, 0, 1, ?4, ?5, 0)",
+ params![copy_id, vault, sealed, stamp, version],
+ )?;
+ }
+ }
+ // Without a version (an app before item history), the next versioned copy may be
+ // from a device that never saw the earlier ones: it starts over.
+ let version = copy.version.unwrap_or(0);
tx.execute(
- "INSERT INTO items (id, vault_id, envelope, deleted, rev, dirty, updated_at, author_id)
- VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?7)
+ "INSERT INTO items (id, vault_id, envelope, deleted, rev, dirty, updated_at, author_id, version, seen_version)
+ VALUES (?1, ?2, ?3, ?4, ?5, 0, ?6, ?7, ?8, ?8)
ON CONFLICT (vault_id, id) DO UPDATE SET envelope = excluded.envelope, deleted = excluded.deleted,
- rev = excluded.rev, dirty = 0, updated_at = excluded.updated_at, author_id = excluded.author_id",
- params![r.id, vault, if r.deleted { None } else { r.envelope.as_deref() }, r.deleted, r.rev, stamp,
- r.author_id.as_deref().filter(|a| vault != keys.vault_id && Some(*a) != keys.account_id.as_deref())],
+ rev = excluded.rev, dirty = 0, updated_at = excluded.updated_at, author_id = excluded.author_id,
+ version = excluded.version, seen_version = excluded.seen_version",
+ params![r.id, vault, if deleted { None } else { r.envelope.as_deref() }, deleted, r.rev, stamp,
+ r.author_id.as_deref().filter(|a| vault != keys.vault_id && Some(*a) != keys.account_id.as_deref()),
+ version],
)?;
applied += 1;
}
@@ -748,10 +812,10 @@ impl LocalVault {
fn mark_pushed(&mut self, vault: &str, sent: &Outgoing, rev: i64) -> Result<()> {
self.conn.execute(
- "UPDATE items SET rev = ?3, author_id = NULL,
+ "UPDATE items SET rev = ?3, author_id = NULL, seen_version = ?5,
dirty = CASE WHEN updated_at = ?4 THEN 0 ELSE 1 END
WHERE vault_id = ?1 AND id = ?2",
- params![vault, sent.id, rev, sent.updated_at],
+ params![vault, sent.id, rev, sent.updated_at, sent.version],
)?;
Ok(())
}
@@ -776,10 +840,13 @@ impl LocalVault {
return Ok(false);
}
let plain = crate::vault::open_envelope(keys, vault, id, &env, author.as_deref())?;
+ // A new version, whatever app wrote it and whatever kind it is.
+ let version = self.next_version(vault, id)?;
+ let plain = history::with_version(&plain, version)?;
let env = envelope::seal(current, &keys.account, &context(vault, id), &plain)?;
self.conn.execute(
- "UPDATE items SET envelope = ?3, author_id = NULL, dirty = 1 WHERE vault_id = ?1 AND id = ?2",
- params![vault, id, env],
+ "UPDATE items SET envelope = ?3, author_id = NULL, dirty = 1, version = ?4 WHERE vault_id = ?1 AND id = ?2",
+ params![vault, id, env, version],
)?;
Ok(true)
}
@@ -881,8 +948,68 @@ fn unlocked_from((account, vault_key): (AccountKeys, VaultKey), import: AccountI
}
}
-/// `(rev, dirty, deleted, envelope, author_id)` of a local item.
-type LocalRow = (i64, bool, bool, Option>, Option);
+/// `(rev, dirty, deleted, envelope, author_id, seen_version)` of a local item.
+type LocalRow = (
+ i64,
+ bool,
+ bool,
+ Option>,
+ Option,
+ Option,
+);
+
+/// Whether the local copy differs from the server's only in what a cloud source decides (each
+/// device refreshed it): no copy is kept, and the next refresh sets it again. A person's edit
+/// (a rename, a note, a tag, a key) still makes one.
+fn source_decides(local: Option<&ItemData>, remote: Option<&ItemData>) -> bool {
+ match (local, remote) {
+ (Some(ItemData::CloudSource(a)), Some(ItemData::CloudSource(b))) => a.same_but_refreshed(b),
+ // A feed's snippets are the feed's: nobody edits them here.
+ (
+ Some(ItemData::Snippet(crate::Snippet {
+ source: Some(_), ..
+ })),
+ _,
+ ) => true,
+ (Some(ItemData::Host(a)), Some(ItemData::Host(b))) => match (&a.source, &b.source) {
+ (Some(x), Some(y))
+ if x.source_ref == y.source_ref && x.external_id == y.external_id =>
+ {
+ persons(a) == persons(b)
+ }
+ _ => false,
+ },
+ _ => false,
+ }
+}
+
+/// What of a source's host is the person's; the rest follows the source.
+fn persons(h: &crate::Host) -> crate::Host {
+ let source = h.source.as_deref();
+ let following = source.is_some_and(|s| s.label == h.label);
+ let from_source = source.map(|s| s.tags.as_slice()).unwrap_or_default();
+ crate::Host {
+ label: if following {
+ String::new()
+ } else {
+ h.label.clone()
+ },
+ hostname: String::new(),
+ port: 0,
+ user: String::new(),
+ jump_ref: None,
+ login_command: None,
+ jump_command: None,
+ tags: h
+ .tags
+ .iter()
+ .filter(|t| !from_source.contains(t))
+ .cloned()
+ .collect(),
+ source: None,
+ ..h.clone()
+ }
+}
fn mark_conflict(data: &mut ItemData) {
const SUFFIX: &str = " (conflict)";
@@ -1122,6 +1249,8 @@ mod tests {
refuse_blobs: Mutex>,
/// Pull requests answered.
pulls: AtomicI64,
+ /// Drop the envelope a deletion carries, as servers from before signed deletions do.
+ drops_deletion_envelopes: std::sync::atomic::AtomicBool,
}
impl Fake {
@@ -1160,7 +1289,12 @@ mod tests {
},
_ => {
let rev = self.head.fetch_add(1, Ordering::SeqCst) + 1;
- let envelope = if o.deleted { None } else { o.envelope };
+ let envelope =
+ if o.deleted && self.drops_deletion_envelopes.load(Ordering::SeqCst) {
+ None
+ } else {
+ o.envelope
+ };
store.insert(
o.id.clone(),
RemoteItem {
@@ -1176,6 +1310,18 @@ mod tests {
})
.collect()
}
+
+ /// The server's copy of item `id`.
+ fn copy(&self, id: &str) -> RemoteItem {
+ self.items.lock().unwrap()[id].clone()
+ }
+
+ /// Makes `copy` the newest revision of its item: a server serving what it kept, or a
+ /// change another app pushed.
+ fn serve(&self, mut copy: RemoteItem) {
+ copy.rev = self.head.fetch_add(1, Ordering::SeqCst) + 1;
+ self.items.lock().unwrap().insert(copy.id.clone(), copy);
+ }
}
impl Remote for Fake {
@@ -1788,6 +1934,114 @@ mod tests {
assert_eq!(labels(&a), labels(&b), "both devices converge");
}
+ /// Two devices refreshing the same cloud source: the hosts and snippets it made have the
+ /// same ids on both, and copies that differ only in what the source decides make no
+ /// "(conflict)" copy. A person's edit still does.
+ #[tokio::test]
+ async fn refreshes_on_two_devices_make_no_copies() {
+ use crate::{CloudSource, HostSource, SnippetSource};
+ let (a, b) = two_devices();
+ let server = Fake::default();
+ let source = CloudSource {
+ label: "Ops".into(),
+ provider: "feed".into(),
+ url: Some("https://ops.example/feed".into()),
+ token: Some("T".into()),
+ ..CloudSource::default()
+ };
+ let sid = a
+ .lock()
+ .unwrap()
+ .upsert(None, ItemData::CloudSource(source.clone()))
+ .unwrap()
+ .id;
+ sync(&a, &server).await;
+ sync(&b, &server).await;
+ let vault = a.lock().unwrap().items().unwrap()[0].vault_id.clone();
+ let kept = |hostname: &str, state: Option<&str>| {
+ let ItemData::Host(mut h) = host("web") else {
+ unreachable!()
+ };
+ h.hostname = hostname.into();
+ h.tags = vec!["prod".into()];
+ h.source = Some(Box::new(HostSource {
+ source_ref: sid.clone(),
+ external_id: "feed:web".into(),
+ label: "web".into(),
+ gone: false,
+ no_sftp: false,
+ no_forwarding: false,
+ state: state.map(str::to_owned),
+ reach: None,
+ host_keys: vec![],
+ tags: vec!["prod".into()],
+ }));
+ ItemData::Host(h)
+ };
+ let snippet = |body: &str| {
+ ItemData::Snippet(Snippet {
+ name: "Uptime".into(),
+ body: body.into(),
+ tags: vec![],
+ source: Some(SnippetSource {
+ source_ref: sid.clone(),
+ external_id: "feed:up".into(),
+ }),
+ })
+ };
+ let refreshed = |at: i64| {
+ ItemData::CloudSource(CloudSource {
+ refreshed_at: at,
+ ..source.clone()
+ })
+ };
+ let host_id = crate::derived_id(&[&vault, &sid, "host", "feed:web"]);
+ let snippet_id = crate::derived_id(&[&vault, &sid, "snippet", "feed:up"]);
+ // Both make them, a moment apart: the feed moved the host and changed the snippet.
+ for (v, hostname, body, at) in [
+ (&a, "10.0.0.1", "uptime", 100),
+ (&b, "10.0.0.2", "uptime -p", 101),
+ ] {
+ let mut v = v.lock().unwrap();
+ v.insert_in(&vault, &host_id, kept(hostname, None)).unwrap();
+ v.insert_in(&vault, &snippet_id, snippet(body)).unwrap();
+ v.upsert(Some(&sid), refreshed(at)).unwrap();
+ }
+ sync(&a, &server).await;
+ let r = sync(&b, &server).await;
+ assert_eq!(r.conflicts, 0);
+ sync(&a, &server).await;
+ let count = |v: &Mutex| v.lock().unwrap().items().unwrap().len();
+ assert_eq!(
+ (count(&a), count(&b)),
+ (3, 3),
+ "no copies: {:?}",
+ labels(&b)
+ );
+
+ // A's person adds a note while B's refresh marks the host stopped: the note is kept.
+ let ItemData::Host(mut noted) = kept("10.0.0.1", None) else {
+ unreachable!()
+ };
+ noted.notes = "ask Ana first".into();
+ a.lock()
+ .unwrap()
+ .upsert(Some(&host_id), ItemData::Host(noted))
+ .unwrap();
+ b.lock()
+ .unwrap()
+ .upsert(Some(&host_id), kept("10.0.0.1", Some("stopped")))
+ .unwrap();
+ sync(&b, &server).await;
+ let r = sync(&a, &server).await;
+ assert_eq!(r.conflicts, 1);
+ assert!(
+ labels(&a).contains(&"web (conflict)".to_owned()),
+ "{:?}",
+ labels(&a)
+ );
+ }
+
#[tokio::test]
async fn edit_beats_delete_as_a_copy_and_delete_loses_to_edit() {
let (a, b) = two_devices();
@@ -1813,6 +2067,7 @@ mod tests {
name: "s".into(),
body: "uptime".into(),
tags: vec![],
+ source: None,
});
let sid = a.lock().unwrap().upsert(None, snip).unwrap().id;
sync(&a, &server).await;
@@ -1825,6 +2080,7 @@ mod tests {
name: "s2".into(),
body: "w".into(),
tags: vec![],
+ source: None,
}),
)
.unwrap();
@@ -1961,4 +2217,342 @@ mod tests {
b.lock();
b.unlock(PW).unwrap();
}
+
+ /// What a device pushes for item `id` of its personal vault: `data` with `version`, or
+ /// without one (`None`: an app before item history), or a deletion (`data: None`), unsigned
+ /// without a version the way those apps send it.
+ fn pushed_by(
+ v: &Mutex,
+ id: &str,
+ data: Option<&ItemData>,
+ version: Option,
+ ) -> RemoteItem {
+ let v = v.lock().unwrap();
+ let keys = v.unlocked().unwrap();
+ let vault = keys.vault_id.clone();
+ let plain = match (data, version) {
+ (Some(data), Some(n)) => history::plaintext(data, n).unwrap().to_vec(),
+ (Some(data), None) => serde_json::to_vec(data).unwrap(),
+ (None, Some(n)) => history::deletion(n),
+ (None, None) => Vec::new(),
+ };
+ let envelope = (!plain.is_empty()).then(|| {
+ envelope::seal(
+ keys.current(&vault).unwrap(),
+ &keys.account,
+ &context(&vault, id),
+ &plain,
+ )
+ .unwrap()
+ });
+ RemoteItem {
+ id: id.to_owned(),
+ rev: 0,
+ deleted: data.is_none(),
+ envelope,
+ author_id: None,
+ }
+ }
+
+ /// The version in this device's copy of item `id` and the last one the server confirmed.
+ fn versions(v: &Mutex, id: &str) -> (Option, Option) {
+ v.lock()
+ .unwrap()
+ .conn
+ .query_row(
+ "SELECT version, seen_version FROM items WHERE id = ?1",
+ [id],
+ |r| Ok((r.get(0)?, r.get(1)?)),
+ )
+ .unwrap()
+ }
+
+ fn refused_as_older(r: core::result::Result) -> bool {
+ matches!(r, Err(SyncError::Store(Error::OlderItem { .. })))
+ }
+
+ /// Every write carries a version; a server serving a device an older copy than it has seen
+ /// gets refused, and the device keeps what it has.
+ #[tokio::test]
+ async fn an_older_copy_of_an_item_is_refused() {
+ let (a, b) = two_devices();
+ let server = Fake::default();
+ let id = a.lock().unwrap().upsert(None, host("db")).unwrap().id;
+ sync(&a, &server).await;
+ let first = server.copy(&id);
+ a.lock().unwrap().upsert(Some(&id), host("db2")).unwrap();
+ sync(&a, &server).await;
+ sync(&b, &server).await;
+ let (version, seen) = versions(&b, &id);
+ assert!(version.unwrap() > 1_700_000_000_000);
+ assert_eq!(version, seen);
+ assert_eq!(versions(&a, &id), (version, version), "pushed is confirmed");
+
+ let second = server.copy(&id);
+ server.serve(first);
+ assert!(refused_as_older(sync_once(&b, &server).await));
+ assert!(
+ refused_as_older(sync_once(&b, &server).await),
+ "until it stops"
+ );
+ assert_eq!(labels(&b), ["db2"]);
+ // The server serves the right copy again: sync goes on.
+ server.serve(second);
+ sync(&b, &server).await;
+ sync(&a, &server).await;
+ a.lock().unwrap().upsert(Some(&id), host("db3")).unwrap();
+ sync(&a, &server).await;
+ sync(&b, &server).await;
+ assert_eq!(labels(&b), ["db3"]);
+ }
+
+ /// A deletion is signed, with a version after the item's: replaying the item's last copy
+ /// doesn't bring it back, and a deletion the server makes up for a newer copy is refused too.
+ #[tokio::test]
+ async fn a_deleted_item_cant_be_brought_back() {
+ let (a, b) = two_devices();
+ let server = Fake::default();
+ let id = a.lock().unwrap().upsert(None, host("db")).unwrap().id;
+ sync(&a, &server).await;
+ sync(&b, &server).await;
+ let live = server.copy(&id);
+ a.lock().unwrap().delete(&id).unwrap();
+ sync(&a, &server).await;
+ let gone = server.copy(&id);
+ assert!(
+ gone.deleted && gone.envelope.is_some(),
+ "the deletion is signed"
+ );
+ sync(&b, &server).await;
+ assert!(labels(&b).is_empty());
+ assert!(versions(&b, &id).1.unwrap() > versions(&a, &id).1.unwrap() - 1);
+
+ server.serve(live);
+ assert!(refused_as_older(sync_once(&b, &server).await));
+ assert!(labels(&b).is_empty());
+
+ // The same item back (moved back, say), then the old deletion again.
+ let back = pushed_by(
+ &a,
+ &id,
+ Some(&host("db again")),
+ Some(history::next(0) + 10),
+ );
+ server.serve(back);
+ sync(&b, &server).await;
+ assert_eq!(labels(&b), ["db again"]);
+ server.serve(gone);
+ assert!(refused_as_older(sync_once(&b, &server).await));
+ assert_eq!(labels(&b), ["db again"]);
+ }
+
+ /// Apps before item history write items without a version and delete without signing:
+ /// their changes still apply, and versions carry on after them.
+ #[tokio::test]
+ async fn changes_from_apps_before_item_history_still_sync() {
+ let (a, b) = two_devices();
+ let server = Fake::default();
+ let id = a.lock().unwrap().upsert(None, host("db")).unwrap().id;
+ sync(&a, &server).await;
+ sync(&b, &server).await;
+
+ server.serve(pushed_by(&a, &id, Some(&host("db (old app)")), None));
+ sync(&b, &server).await;
+ assert_eq!(labels(&b), ["db (old app)"]);
+ assert_eq!(versions(&b, &id), (Some(0), Some(0)));
+
+ // After it, a device that never saw the earlier versions may write a lower one (its
+ // clock behind, say): it counts from there.
+ server.serve(pushed_by(&a, &id, Some(&host("db (slow clock)")), Some(5)));
+ sync(&b, &server).await;
+ assert_eq!(labels(&b), ["db (slow clock)"]);
+ server.serve(pushed_by(&a, &id, Some(&host("db (older)")), Some(4)));
+ assert!(refused_as_older(sync_once(&b, &server).await));
+
+ // B's next edit comes after all of it; A takes it.
+ server.serve(pushed_by(&a, &id, Some(&host("db (slow clock)")), Some(5)));
+ sync(&b, &server).await;
+ b.lock()
+ .unwrap()
+ .upsert(Some(&id), host("db (new app)"))
+ .unwrap();
+ sync(&b, &server).await;
+ sync(&a, &server).await;
+ assert_eq!(labels(&a), ["db (new app)"]);
+
+ server.serve(pushed_by(&a, &id, None, None));
+ sync(&a, &server).await;
+ assert!(labels(&a).is_empty(), "an unsigned deletion applies");
+ }
+
+ /// With strict item history, an item that has a version takes no unversioned change and no
+ /// unsigned deletion; items that never had a version still do.
+ #[tokio::test]
+ async fn strict_history_refuses_unversioned_changes() {
+ let (a, b) = two_devices();
+ let server = Fake::default();
+ b.lock().unwrap().set_strict_history(true);
+ let id = a.lock().unwrap().upsert(None, host("db")).unwrap().id;
+ sync(&a, &server).await;
+ sync(&b, &server).await;
+ let current = server.copy(&id);
+ let unversioned = |r: core::result::Result| {
+ matches!(r, Err(SyncError::Store(Error::UnversionedItem { .. })))
+ };
+
+ server.serve(pushed_by(&a, &id, Some(&host("db (old app)")), None));
+ assert!(unversioned(sync_once(&b, &server).await));
+ server.serve(pushed_by(&a, &id, None, None));
+ assert!(unversioned(sync_once(&b, &server).await));
+ // A deletion envelope that isn't one: a live copy, or another item's.
+ let mut fake = current.clone();
+ fake.deleted = true;
+ server.serve(fake);
+ assert!(unversioned(sync_once(&b, &server).await));
+ assert_eq!(labels(&b), ["db"]);
+ server.serve(current);
+ sync(&b, &server).await;
+
+ let old = Ulid::generate().to_string();
+ server.serve(pushed_by(&a, &old, Some(&host("from an old app")), None));
+ sync(&b, &server).await;
+ assert_eq!(labels(&b), ["db", "from an old app"]);
+ server.serve(pushed_by(&a, &old, None, None));
+ sync(&b, &server).await;
+ assert_eq!(labels(&b), ["db"]);
+ }
+
+ /// A copy stored before item history (no versions in its row) still counts with the version
+ /// its envelope holds: the next write comes after it, and nothing older is taken.
+ #[tokio::test]
+ async fn copies_from_before_item_history_keep_their_version() {
+ let (a, b) = two_devices();
+ let server = Fake::default();
+ let id = a.lock().unwrap().upsert(None, host("db")).unwrap().id;
+ sync(&a, &server).await;
+ let ahead = history::next(0) + 86_400_000;
+ server.serve(pushed_by(
+ &a,
+ &id,
+ Some(&host("db (clock ahead)")),
+ Some(ahead),
+ ));
+ sync(&b, &server).await;
+ b.lock()
+ .unwrap()
+ .conn
+ .execute("UPDATE items SET version = NULL, seen_version = NULL", [])
+ .unwrap();
+
+ server.serve(pushed_by(
+ &a,
+ &id,
+ Some(&host("db (older)")),
+ Some(ahead - 1),
+ ));
+ assert!(refused_as_older(sync_once(&b, &server).await));
+
+ server.serve(pushed_by(
+ &a,
+ &id,
+ Some(&host("db (clock ahead)")),
+ Some(ahead),
+ ));
+ sync(&b, &server).await;
+ b.lock()
+ .unwrap()
+ .conn
+ .execute("UPDATE items SET version = NULL, seen_version = NULL", [])
+ .unwrap();
+ b.lock()
+ .unwrap()
+ .upsert(Some(&id), host("db (edited)"))
+ .unwrap();
+ assert_eq!(versions(&b, &id).0, Some(ahead + 1));
+ }
+
+ /// Re-sealing under a vault's new key (finishing a rotation) is a write: a new version, the
+ /// same content.
+ #[test]
+ fn resealing_gives_a_new_version() {
+ let mut v = fast(LocalVault::open_in_memory().unwrap());
+ v.create(PW).unwrap();
+ let old = VaultKey::generate().unwrap();
+ v.conn
+ .execute(
+ "INSERT INTO vaults (id, key_id, wrapped_vault_key, created_at, personal, org_id, permission, name)
+ VALUES ('TEAM', ?1, x'02', 1, 0, 'ORG', 'write', 'Team')",
+ [old.id().as_slice()],
+ )
+ .unwrap();
+ let ring = |keys: Vec| crate::vault::Ring {
+ current: *keys.last().unwrap().id(),
+ keys,
+ access: Access::Write,
+ };
+ let new = VaultKey::generate().unwrap();
+ let (old_id, new_id) = (*old.id(), *new.id());
+ v.keys
+ .as_mut()
+ .unwrap()
+ .shared
+ .insert("TEAM".into(), ring(vec![old]));
+ let id = v.upsert_in("TEAM", None, host("db")).unwrap().id;
+ let before: i64 = v
+ .conn
+ .query_row("SELECT version FROM items WHERE id = ?1", [&id], |r| {
+ r.get(0)
+ })
+ .unwrap();
+ v.keys
+ .as_mut()
+ .unwrap()
+ .shared
+ .get_mut("TEAM")
+ .unwrap()
+ .keys
+ .push(new);
+ v.keys
+ .as_mut()
+ .unwrap()
+ .shared
+ .get_mut("TEAM")
+ .unwrap()
+ .current = new_id;
+ assert_ne!(old_id, new_id);
+
+ assert_eq!(v.reseal_old_items("TEAM").unwrap(), 1);
+ let (env, version): (Vec, i64) = v
+ .conn
+ .query_row(
+ "SELECT envelope, version FROM items WHERE id = ?1",
+ [&id],
+ |r| Ok((r.get(0)?, r.get(1)?)),
+ )
+ .unwrap();
+ assert_eq!(envelope_key_id(&env).unwrap(), new_id);
+ assert!(version > before);
+ let keys = v.unlocked().unwrap();
+ let copy =
+ history::read(&id, &open_envelope(keys, "TEAM", &id, &env, None).unwrap()).unwrap();
+ assert_eq!((copy.data, copy.version), (Some(host("db")), Some(version)));
+ }
+
+ /// A server from before signed deletions drops their envelope: deletions still sync.
+ #[tokio::test]
+ async fn deletions_sync_through_servers_that_drop_their_envelope() {
+ let (a, b) = two_devices();
+ let server = Fake::default();
+ server
+ .drops_deletion_envelopes
+ .store(true, Ordering::SeqCst);
+ let id = a.lock().unwrap().upsert(None, host("db")).unwrap().id;
+ sync(&a, &server).await;
+ sync(&b, &server).await;
+ a.lock().unwrap().delete(&id).unwrap();
+ sync(&a, &server).await;
+ assert!(server.copy(&id).envelope.is_none());
+ sync(&b, &server).await;
+ assert!(labels(&b).is_empty());
+ }
}
diff --git a/crates/mg-store/src/sync_http.rs b/crates/mg-store/src/sync_http.rs
index 97586b6..8b10da1 100644
--- a/crates/mg-store/src/sync_http.rs
+++ b/crates/mg-store/src/sync_http.rs
@@ -14,6 +14,9 @@ const PAGE: u32 = 500;
/// The organization and why, when the server held its vaults back.
pub(crate) fn held(e: &mg_sync::Error) -> Option<(String, Hold)> {
match e {
+ mg_sync::Error::TotpSetupRequired { org_id } if !org_id.is_empty() => {
+ Some((org_id.clone(), Hold::Totp))
+ }
mg_sync::Error::SsoRequired { org_id } => Some((org_id.clone(), Hold::Sso)),
mg_sync::Error::DeviceApprovalRequired { org_id, denied } => Some((
org_id.clone(),
diff --git a/crates/mg-store/src/vault.rs b/crates/mg-store/src/vault.rs
index 4bda5dd..8c3bbad 100644
--- a/crates/mg-store/src/vault.rs
+++ b/crates/mg-store/src/vault.rs
@@ -11,7 +11,7 @@ use ulid::Ulid;
use zeroize::Zeroizing;
use crate::model::SCHEMA_VERSION;
-use crate::{Error, ForwardRule, Host, HostAuth, Item, ItemData, Result, schema};
+use crate::{Error, ForwardRule, Host, HostAuth, Item, ItemData, Result, history, schema};
pub const MIN_PASSWORD_CHARS: usize = 10;
/// Longest allowed chain of jump hosts in front of a target.
@@ -175,6 +175,9 @@ pub struct LocalVault {
pub(crate) conn: Connection,
pub(crate) keys: Option,
kdf_cost: Option<(u32, u32, u32)>,
+ /// Refuse unversioned changes to items that have a version (see
+ /// [`set_strict_history`](Self::set_strict_history)).
+ pub(crate) strict_history: bool,
}
impl LocalVault {
@@ -207,9 +210,17 @@ impl LocalVault {
conn,
keys: None,
kdf_cost: None,
+ strict_history: false,
})
}
+ /// Strict item history: once an item has a version here, a change to it without one or a
+ /// deletion without a signature (what apps before item history send, and what a server
+ /// could make up) is refused instead of applied. Off while such apps may still write.
+ pub fn set_strict_history(&mut self, strict: bool) {
+ self.strict_history = strict;
+ }
+
/// Argon2id cost (`m_kib`, `t`, `p`) for passwords set from now on. The `mg-vault` floors still
/// apply. Defaults to [`KdfParams::recommended`].
pub fn with_kdf_cost(mut self, m_kib: u32, t: u32, p: u32) -> Self {
@@ -631,6 +642,35 @@ impl LocalVault {
})
}
+ /// Creates item `id` in `vault_id`: an id worked out rather than drawn ([`derived_id`]), so
+ /// two devices making the same item write the same one, and the second gets a conflict
+ /// instead of a duplicate. Refused when a live item has the id.
+ pub fn insert_in(&mut self, vault_id: &str, id: &str, data: ItemData) -> Result- {
+ if self.unlocked()?.access(vault_id)? < Access::Write {
+ return Err(Error::ReadOnly);
+ }
+ if data == ItemData::Unknown {
+ return Err(Error::Invalid(
+ "this app can't edit that kind of item".into(),
+ ));
+ }
+ let live: bool = self.conn.query_row(
+ "SELECT EXISTS (SELECT 1 FROM items WHERE id = ?1 AND deleted = 0)",
+ [id],
+ |r| r.get(0),
+ )?;
+ if live {
+ return Err(Error::Invalid(format!("item {id} already exists")));
+ }
+ let now = self.put(vault_id, id, &data)?;
+ Ok(Item {
+ id: id.to_owned(),
+ vault_id: vault_id.to_owned(),
+ updated_at: now,
+ data,
+ })
+ }
+
/// Checks `data` as an item `id` of `vault`, seals it with the vault's current key and stores
/// it for the next sync (over a tombstone of the same id, too). Returns the time stamped.
pub(crate) fn put(&mut self, vault_id: &str, id: &str, data: &ItemData) -> Result
{
@@ -641,9 +681,9 @@ impl LocalVault {
/// [`put`](Self::put) without the checks, for writes that are checked together afterwards
/// (a move, whose items may refer to each other in a circle).
pub(crate) fn write(&mut self, vault_id: &str, id: &str, data: &ItemData) -> Result {
+ let version = self.next_version(vault_id, id)?;
let keys = self.unlocked()?;
- let plain =
- Zeroizing::new(serde_json::to_vec(data).map_err(|e| Error::Invalid(e.to_string()))?);
+ let plain = history::plaintext(data, version)?;
let env = envelope::seal(
keys.current(vault_id)?,
&keys.account,
@@ -652,14 +692,62 @@ impl LocalVault {
)?;
let now = now();
self.conn.execute(
- "INSERT INTO items (id, vault_id, envelope, deleted, dirty, updated_at, author_id) VALUES (?1, ?2, ?3, 0, 1, ?4, NULL)
+ "INSERT INTO items (id, vault_id, envelope, deleted, dirty, updated_at, author_id, version, seen_version)
+ VALUES (?1, ?2, ?3, 0, 1, ?4, NULL, ?5, 0)
ON CONFLICT (vault_id, id) DO UPDATE SET envelope = excluded.envelope, deleted = 0, dirty = 1,
- updated_at = excluded.updated_at, author_id = NULL",
- params![id, vault_id, env, now],
+ updated_at = excluded.updated_at, author_id = NULL,
+ version = excluded.version",
+ params![id, vault_id, env, now, version],
)?;
Ok(now)
}
+ /// The version for the next write of item `id` of `vault`: after the highest this device
+ /// knows of it (its own copy's, or the last one the server confirmed).
+ pub(crate) fn next_version(&self, vault: &str, id: &str) -> Result {
+ let keys = self.unlocked()?;
+ let row: Option = self
+ .conn
+ .query_row(
+ "SELECT version, seen_version, envelope, author_id FROM items WHERE vault_id = ?1 AND id = ?2",
+ params![vault, id],
+ |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
+ )
+ .optional()?;
+ let known = match row {
+ None => 0,
+ Some((version, seen, env, author)) => {
+ let own = match (version, env) {
+ (Some(v), _) => v,
+ // A copy from before item history: its envelope may still hold a version
+ // (another app wrote it).
+ (None, Some(env)) => version_in(keys, vault, id, &env, author.as_deref()),
+ (None, None) => 0,
+ };
+ own.max(seen.unwrap_or(0))
+ }
+ };
+ Ok(history::next(known))
+ }
+
+ /// Replaces item `id` of `vault` with a signed deletion for the next sync.
+ pub(crate) fn tombstone(&mut self, vault: &str, id: &str, stamp: i64) -> Result<()> {
+ let version = self.next_version(vault, id)?;
+ let keys = self.unlocked()?;
+ let env = envelope::seal(
+ keys.current(vault)?,
+ &keys.account,
+ &context(vault, id),
+ &history::deletion(version),
+ )?;
+ self.conn.execute(
+ "UPDATE items SET envelope = ?3, deleted = 1, dirty = 1, updated_at = ?4, version = ?5
+ WHERE vault_id = ?1 AND id = ?2",
+ params![vault, id, env, stamp, version],
+ )?;
+ Ok(())
+ }
+
/// Replaces the item with a tombstone so the deletion can sync.
pub fn delete(&mut self, id: &str) -> Result<()> {
let item = self.item(id)?;
@@ -683,6 +771,9 @@ impl LocalVault {
ItemData::Host(h) if h.source.as_ref().is_some_and(|s| s.source_ref == id) => {
Some(h.label)
}
+ ItemData::Snippet(s) if s.source.as_ref().is_some_and(|s| s.source_ref == id) => {
+ Some(format!("snippet {}", s.name))
+ }
ItemData::CloudSource(c)
if c.key_ref.as_deref() == Some(id) || c.jump_ref.as_deref() == Some(id) =>
{
@@ -726,11 +817,7 @@ impl LocalVault {
users.join(", ")
)));
}
- self.conn.execute(
- "UPDATE items SET envelope = NULL, deleted = 1, dirty = 1, updated_at = ?3
- WHERE vault_id = ?1 AND id = ?2",
- params![item.vault_id, id, now()],
- )?;
+ self.tombstone(&item.vault_id, id, now())?;
// A session log's recording, or an attachment's file, goes with it.
let blobs = item.data.blob_refs();
if !blobs.is_empty() {
@@ -1063,6 +1150,14 @@ impl LocalVault {
if s.name.trim().is_empty() {
return invalid("snippet name is required");
}
+ if let Some(source) = &s.source {
+ self.expect(
+ vault,
+ &source.source_ref,
+ "the snippet's feed does not exist",
+ |d| matches!(d, ItemData::CloudSource(_)),
+ )?;
+ }
}
ItemData::MemberKey(k) => {
if k.account_id.is_empty() || k.public_key.split_ascii_whitespace().count() != 2 {
@@ -1169,13 +1264,36 @@ impl LocalVault {
}
}
ItemData::CloudSource(c) => {
- if !["vallic", "aws", "gcp", "azure", "hetzner", "digitalocean"]
- .contains(&c.provider.as_str())
+ if ![
+ "vallic",
+ "aws",
+ "gcp",
+ "azure",
+ "hetzner",
+ "digitalocean",
+ "feed",
+ ]
+ .contains(&c.provider.as_str())
{
return invalid(
- "a cloud source's provider is vallic, aws, gcp, azure, hetzner or digitalocean",
+ "a cloud source's provider is vallic, aws, gcp, azure, hetzner, digitalocean or feed",
);
}
+ if c.provider == "feed" {
+ let url = c.url.as_deref().unwrap_or("");
+ let local = ["localhost", "127.0.0.1", "[::1]"].iter().any(|h| {
+ url.strip_prefix("http://").is_some_and(|rest| {
+ rest.strip_prefix(h)
+ .is_some_and(|r| r.is_empty() || r.starts_with([':', '/']))
+ })
+ });
+ if !(url.starts_with("https://") && url.len() > 8) && !local {
+ return invalid("a feed's address starts with https://");
+ }
+ if c.token.as_deref().is_none_or(|t| t.trim().is_empty()) {
+ return invalid("a feed needs the token it's read with");
+ }
+ }
if let Some(key) = &c.key_ref {
self.expect(vault, key, "the selected key does not exist", |d| {
matches!(d, ItemData::SshKey(_))
@@ -1465,6 +1583,24 @@ pub(crate) fn open_envelope(
Ok(envelope::open(vk, &signer, &context(vault, id), env)?)
}
+/// `(version, seen_version, envelope, author_id)` of a local item.
+type VersionRow = (Option, Option, Option>, Option);
+
+/// The version in a stored envelope of item `id`; 0 when it has none or can't be read.
+pub(crate) fn version_in(
+ keys: &Unlocked,
+ vault: &str,
+ id: &str,
+ env: &[u8],
+ author: Option<&str>,
+) -> i64 {
+ open_envelope(keys, vault, id, env, author)
+ .and_then(|plain| history::read(id, &plain))
+ .ok()
+ .and_then(|copy| copy.version)
+ .unwrap_or(0)
+}
+
pub(crate) fn decrypt(
keys: &Unlocked,
vault: &str,
@@ -1476,6 +1612,24 @@ pub(crate) fn decrypt(
serde_json::from_slice(&plain).map_err(|e| Error::Corrupt(format!("item {id}: {e}")))
}
+/// An item id worked out from `parts` rather than drawn: SHA-256 of them, as a ULID. Every
+/// device that makes the item from the same parts (a source's vault, the source, what it lists)
+/// gives it the same id.
+pub fn derived_id(parts: &[&str]) -> String {
+ use sha2::{Digest, Sha256};
+ let mut hash = Sha256::new();
+ hash.update(b"morphgate derived item id v1");
+ for p in parts {
+ // Each part with its length: no two lists of parts run together the same.
+ hash.update((p.len() as u64).to_be_bytes());
+ hash.update(p.as_bytes());
+ }
+ let digest = hash.finalize();
+ let mut bytes = [0u8; 16];
+ bytes.copy_from_slice(&digest[..16]);
+ Ulid::from_bytes(bytes).to_string()
+}
+
pub(crate) fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -1731,6 +1885,7 @@ mod tests {
name: "disk".into(),
body: "df -h".into(),
tags: vec![],
+ source: None,
}),
)
.unwrap();
@@ -1746,6 +1901,12 @@ mod tests {
v.upsert(Some(&h.id), edited.clone()).unwrap();
assert_eq!(v.item(&h.id).unwrap().data, edited);
+ let before: i64 = v
+ .conn
+ .query_row("SELECT version FROM items WHERE id = ?1", [&s.id], |r| {
+ r.get(0)
+ })
+ .unwrap();
v.delete(&s.id).unwrap();
assert!(matches!(v.item(&s.id), Err(Error::NotFound(_))));
assert_eq!(v.items().unwrap().len(), 2);
@@ -1757,10 +1918,19 @@ mod tests {
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
- assert!(
- deleted && envelope.is_none() && dirty,
- "deletes become syncable tombstones"
- );
+ assert!(deleted && dirty, "deletes become syncable tombstones");
+ // Signed by this account, for this item, with a version after the item's.
+ let plain = open_envelope(
+ v.unlocked().unwrap(),
+ &s.vault_id,
+ &s.id,
+ &envelope.expect("a signed deletion"),
+ None,
+ )
+ .unwrap();
+ let gone = history::read(&s.id, &plain).unwrap();
+ assert!(gone.data.is_none());
+ assert!(gone.version.unwrap() > before);
}
#[test]
diff --git a/crates/mg-sync/src/client.rs b/crates/mg-sync/src/client.rs
index c942d37..3b242b6 100644
--- a/crates/mg-sync/src/client.rs
+++ b/crates/mg-sync/src/client.rs
@@ -31,6 +31,10 @@ pub enum Error {
/// `402`: the account's or organization's plan doesn't cover this.
#[error("{}", payment_message(.0))]
PaymentRequired(Box),
+ /// `403 totp_setup_required`: the organization's vaults need two-step verification on the
+ /// account (`org_id` empty from servers that don't say which organization).
+ #[error("the organization requires two-step verification; turn it on first")]
+ TotpSetupRequired { org_id: String },
/// `403 sso_required`: the organization's vaults need this device signed in at its
/// identity provider (`sso_start`).
#[error("the organization's vaults need single sign-on")]
@@ -932,6 +936,11 @@ async fn error(resp: reqwest::Response) -> Error {
.and_then(|v| v.as_u64())
.and_then(|n| u32::try_from(n).ok()),
})),
+ StatusCode::FORBIDDEN if field("code").as_deref() == Some("totp_setup_required") => {
+ Error::TotpSetupRequired {
+ org_id: field("org_id").unwrap_or_default(),
+ }
+ }
StatusCode::FORBIDDEN if field("code").as_deref() == Some("sso_required") => {
Error::SsoRequired {
org_id: field("org_id").unwrap_or_default(),
diff --git a/crates/mg-sync/src/types.rs b/crates/mg-sync/src/types.rs
index a567e7e..db8045e 100644
--- a/crates/mg-sync/src/types.rs
+++ b/crates/mg-sync/src/types.rs
@@ -49,7 +49,8 @@ impl<'de> Deserialize<'de> for B64 {
pub struct ApiError {
pub code: String,
pub message: String,
- /// With `403 sso_required` and `403 device_approval_required`: the organization.
+ /// With `403 totp_setup_required`, `403 sso_required` and `403 device_approval_required`:
+ /// the organization.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub org_id: Option,
/// With `403 device_approval_required`: the calling device.
@@ -127,8 +128,15 @@ pub struct Features {
/// Organizations can put their vaults behind their identity provider.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sso: Option,
+ /// The server keeps the envelope a deletion carries (the item's signed deletion, at most
+ /// [`MAX_DELETION_ENVELOPE`] bytes) and returns it with the tombstone.
+ #[serde(default)]
+ pub signed_tombstones: bool,
}
+/// The most a deletion's envelope may take (`PushItem::envelope` with `deleted`).
+pub const MAX_DELETION_ENVELOPE: usize = 1024;
+
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BlobFeatures {
pub max_blob_bytes: u64,
diff --git a/crates/mg-vpn/src/lib.rs b/crates/mg-vpn/src/lib.rs
index ecb7ff0..b8c4fa2 100644
--- a/crates/mg-vpn/src/lib.rs
+++ b/crates/mg-vpn/src/lib.rs
@@ -312,6 +312,7 @@ pub(crate) mod testutil {
connect_timeout: Duration::from_secs(10),
jumps: vec![],
via_command: None,
+ listed_keys: None,
dialer: Some(Arc::new(Via(tunnel))),
forward_agent: None,
};
diff --git a/docs/README.md b/docs/README.md
index 8f648b9..0f5637a 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -26,7 +26,7 @@ Start with [Architecture](architecture.md), then the feature you're working on.
| [SFTP](features/sftp.md) | The file browser, transfers, conflicts |
| [Session logs](features/session-logs.md) | Recording sessions, sealed chunks, uploading and playback |
| [Importing](features/importing.md) | Hosts from OpenSSH, PuTTY, CSV and Royal TS; snippets from CSV |
-| [Cloud sources](features/cloud-sources.md) | Hosts from Vallic Cloud, AWS, Google Cloud, Azure, Hetzner Cloud and DigitalOcean |
+| [Cloud sources](features/cloud-sources.md) | Hosts from Vallic Cloud, AWS, Google Cloud, Azure, Hetzner Cloud and DigitalOcean, and hosts and snippets from a feed |
| **Sync and teams** | |
| [Sync](features/sync.md) | Choosing a server, signing up and in, two-step verification, the push and pull cycle, conflicts, plans and limits |
| [Account and devices](features/account-and-devices.md) | The device list, signing devices out, erasing a revoked device, disconnecting, deleting the account, the website account page, device approval, single sign-on |
diff --git a/docs/architecture.md b/docs/architecture.md
index c9bce4e..cbfaacf 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -39,7 +39,7 @@ How the MorphGate app is put together: the webview, the Tauri commands behind it
| `mg-ssh` | `crates/mg-ssh` | One `Session` per connection on `russh`: PTY shell, authentication, host-key pinning, agents, port forwards, SFTP, `ssh_config` parsing. See [SSH connections](features/ssh-connections.md) |
| `mg-vpn` | `crates/mg-vpn` | VPN tunnels that run inside the app with their own smoltcp stack; `Tunnel::connect` returns a byte stream to a host behind the VPN. See [VPN](features/vpn.md) |
| `mg-presence` | `crates/mg-presence` | Asks the OS to confirm the user is there before the keychain unlock. See [Keychain and biometric unlock](features/device-unlock.md) |
-| `mg-inventory` | `crates/mg-inventory` | Lists machines from Vallic Cloud (API) and AWS, Google Cloud, Azure, Hetzner Cloud, DigitalOcean (their CLIs). See [Cloud sources](features/cloud-sources.md) |
+| `mg-inventory` | `crates/mg-inventory` | Lists machines from Vallic Cloud (API) and AWS, Google Cloud, Azure, Hetzner Cloud, DigitalOcean (their CLIs), and reads feeds (hosts and snippets at a URL). See [Cloud sources](features/cloud-sources.md) |
| Reference server | `server/` (`vault-sync-server`) | A self-hostable `vault-sync/v1` server. See [Reference server](features/reference-server.md) |
| Conformance suite | `tools/vault-conform` | Checks any `vault-sync/v1` server against the protocol, endpoint by endpoint. See [Development](development.md#conformance-suite) |
@@ -172,7 +172,7 @@ See [Sync](features/sync.md), [Account and devices](features/account-and-devices
| The sync server | Items are signed and checked before decrypting; a revision older than one already seen is an error; KDF parameters below the minimum are refused; shared vault keys count only when signed by a member this device verified, and a changed member key is a hard error |
| Other members of a shared vault | A command that arrives in a vault (a proxy command, a certificate renewal command) runs only after this device allowed it, by its exact text |
| The webview | Private keys and passwords go into Rust and never come back out to the webview |
-| Its own server, with cloud credentials | Cloud provider calls go from the device to the provider; the credentials never reach the sync server |
+| Its own server, with cloud credentials | Cloud provider calls go from the device to the provider; the credentials never reach the sync server (a feed's read-only token travels in the vault, end-to-end encrypted) |
| Links | A `morphgate://` link only opens a screen, filled in; it never joins, connects or changes anything, and passwords or keys in it are ignored |
What it relies on: the OS user account (the keychain unlock and the presence check keep someone at an unlocked computer out, not malware running as the user) and the host keys the user accepts.
diff --git a/docs/development.md b/docs/development.md
index 66d3a86..5d7dd72 100644
--- a/docs/development.md
+++ b/docs/development.md
@@ -98,24 +98,30 @@ They cover tls-auth, tls-crypt, TCP, CBC with the PRF, LZO and renegotiation ([V
apps/desktop/e2e/run.sh # builds test packages (about 10 minutes), then every scenario
E2E_SKIP_BUILD=1 apps/desktop/e2e/run.sh # reuses the last build
E2E_SKIP_BUILD=1 E2E_ONLY="teams mobile" apps/desktop/e2e/run.sh # appimage, rpm, deb, teams, layout, mobile
+E2E_SKIP_BUILD=1 E2E_ONLY=demo apps/desktop/e2e/run.sh # only when asked: the README's demo
+E2E_SKIP_BUILD=1 E2E_ONLY=monkey MONKEY_STEPS=1500 apps/desktop/e2e/run.sh # only when asked: a monkey test of each package
+node apps/desktop/e2e/android-monkey.mjs target/e2e/android-monkey # Android's monkey, on an emulator adb reaches
```
| Scenario | Checks |
| --- | --- |
-| `appimage.mjs` | A vault; SSH to the throwaway sshd; imports from `~/.ssh/config`, PuTTY (`.ppk`), a Termius-style CSV and Royal TS; Vallic Cloud through a stand-in console; user and host certificates; proxy and login commands; groups, bulk move and delete; an agent and agent forwarding; split panes and broadcast; a `morphgate://connect` link from a second launch; appearance; About and the license list; a tampered update refused, the signed update installed and the same vault unlocked in the new version |
+| `appimage.mjs` | A vault (sync offered a week later, once, not with it); SSH to the throwaway sshd; imports from `~/.ssh/config`, PuTTY (`.ppk`), a Termius-style CSV and Royal TS; Vallic Cloud, AWS, DigitalOcean and a feed through stand-ins; user and host certificates; proxy and login commands; groups, bulk move and delete; an agent and agent forwarding; split panes and broadcast; a `morphgate://connect` link from a second launch; appearance; About and the license list; a tampered update refused, the signed update installed and the same vault unlocked in the new version |
| `package.mjs` (deb, rpm) | The desktop entry handles `morphgate://`; the binary knows its package, finds the update and warns about the administrator password |
| `teams.mjs` | Three apps (`E2E_PEOPLE="alice bob carol"`; Carol's app is Bob's second device) and the reference server: sync, an organization, invites with fingerprint checks, shared edits, moving items, published member keys, revocation and rotation, policies, the activity log and its export, device approval, attachments, offboarding against the sshd, shared credentials and tmux sessions, single sign-on against `idp.mjs`, the data export, signing a device out, deleting accounts |
| `layout.mjs` | Every page filled, at 860, 1024, 1280 and 1600 px: nothing overlaps or leaves its column; closing editors and pages returns where they came from |
| `mobile.mjs` | The phone layout in a 390 × 844 window ([Platforms and mobile](features/platforms-and-mobile.md#tests)) |
+| `monkey.mjs` (`E2E_ONLY=monkey` only) | A monkey test of the AppImage, the deb and the rpm in turn: a vault with a key, groups, hosts (pointing where nothing listens, so no server's files are in reach) and a snippet, then `MONKEY_STEPS` (500) seeded random steps: clicks on whatever is enabled, typing anything from digits to emoji and 2,000-character strings, Escape, Enter (submitting forms), Tab and arrows, selects, typing into sessions (never Enter), the sidebar, window sizes from a phone's to 1600 px. After every step the page must have thrown nothing (errors and unhandled promise rejections), still render and still answer; afterwards no log may hold a Rust panic. It doesn't click what ends the walk or opens a native dialog (Lock, Install and restart, file pickers, exports, erasing). A failure writes `failure.txt` (the seed, the step and the last 25 steps) and `failure.png` in `monkey-/`; `MONKEY_SEED` repeats a walk |
+| `android-monkey.mjs` (not in `run.sh`; CI's "Android monkey" job) | Android's monkey (`adb shell monkey`) on a debug build (`npx tauri android build --debug --apk --target x86_64`) in an emulator: the vault created on its first screen and unlocked on its unlock screen, typed over the WebView's DevTools (the page is never reloaded), once the device is done with its start-up work, `MONKEY_ROUNDS` (5) rounds of `MONKEY_EVENTS` (1000) touches, swipes, screen turns and navigation keys, each from a fresh start with the app's task pinned. A crash, an ANR, a Rust panic or Java exception in logcat, an error the page throws, the process ending, a blank page or an app that no longer draws fails it, with the seed in `failure.txt` (`MONKEY_SEED`); on an emulator an ANR's main-thread stack goes to `anr.txt`. It clears the app's data first. CI runs a basic one in an API 35 emulator on every pull request, not as a required check: 3 rounds of 500 events on a 720 × 1600 screen, with `MONKEY_ANR=note` (the emulator draws in software, a frame can take longer than Android's 5 seconds for a touch, so ANRs are noted; the app still has to draw again) |
+| `demo.mjs` (`E2E_ONLY=demo` only) | The README's demo: the AppImage with a few hosts in groups. Its Hosts page is captured as shapes (`tools/readme-demo/snapshot.js`: boxes, text and icons where the layout put them), web-1 is clicked, and its session view is captured and recorded by the app's session recording (the login command runs a clean bash with a demo prompt in a demo home). `tools/readme-demo/demo-svg.mjs` draws both scenes, a pointer's click and the replayed session (xterm.js headless; each row's states are a strip a CSS animation steps through) as one animated SVG in the app's fonts. This machine's user and loopback address are swapped for demo ones, and nothing that still shows them or your home folder is saved. Copy `target/e2e/run-/demo/morphgate.svg` to `assets/demo/morphgate.svg` |
What `run.sh` sets up, all on loopback and on free ports (`E2E_SSH_PORT`, `E2E_FEED_PORT`, `E2E_SYNC_PORT`, `E2E_IDP_PORT`, `E2E_VALLIC_PORT`, `E2E_DRIVER_PORT` pin them):
| Piece | What it is |
| --- | --- |
| Test packages | The current version (AppImage, deb, rpm) and the next patch version (AppImage), signed with a throwaway update key whose public half replaces the real one, and allowed to fetch updates over plain HTTP. Moved out of `target/release/bundle` to `target/e2e/packages/`; never publish them |
-| sshd | On 127.0.0.1, 127.0.0.2 and 127.0.0.3, key login for the current user, a user CA it trusts and a host certificate for 127.0.0.3; SFTP starts in the run's `sshd/home`, whose `authorized_keys` is the one sshd reads, so offboarding never edits your real `~/.ssh` |
+| sshd | On 127.0.0.1, 127.0.0.2, 127.0.0.3 and 127.0.0.4, key login for the current user, a user CA it trusts and a host certificate for 127.0.0.3; SFTP starts in the run's `sshd/home`, whose `authorized_keys` is the one sshd reads, so offboarding never edits your real `~/.ssh` |
| `feed.mjs` | The update feed (`MORPHGATE_UPDATE_ENDPOINT`), serving a good and a tampered `latest.json` |
-| `vallic-console.mjs`, `clouds/aws` | Stand-ins for the Vallic console and the `aws` CLI, whose machine is the sshd |
+| `vallic-console.mjs`, `clouds/aws`, `clouds/doctl`, `host-feed.mjs` | Stand-ins for the Vallic console, the `aws` and `doctl` CLIs and a feed (`E2E_HOST_FEED`, with its token in `E2E_HOST_FEED_TOKEN`), whose machines are the sshd (the feed's on 127.0.0.4, with its host key listed) |
| `vault-sync-server`, `idp.mjs` | The reference server (open registration, invite codes instead of mail) and an OpenID provider, for `teams` and `layout` |
| `session.sh` | One `tauri-driver` per person, each with its own `HOME` and XDG folders in the run directory, so your vault, session bus and keyring are never touched |
@@ -170,7 +176,7 @@ The reference server's tests (`server/tests/`) are the older conformance suite:
All jobs run on GitHub's runners.
-`.github/workflows/release.yml` builds releases on pushes to `develop` and `main` ([Releasing](#releasing)): a `plan` job (channel, version, build number, platforms, the release to upload to), the desktop matrix through `tauri-action`, the Android APK, and on iOS the signed `.ipa` and a `testflight` upload job on Linux.
+`.github/workflows/release.yml` builds releases on pushes to `develop` and `main` ([Releasing](#releasing)): a `plan` job (channel, version, build number, platforms, the release to upload to), the desktop matrix through `tauri-action`, a `macos-notarize` job for the DMG, the Android APK, and on iOS the signed `.ipa` and a `testflight` upload job on Linux. The jobs that only talk to Apple are apart from the builds, so re-running a failed one doesn't build again; Windows signing retries the timestamp server inside the build ([RELEASING.md](../RELEASING.md)).
## Versioning
diff --git a/docs/features/account-and-devices.md b/docs/features/account-and-devices.md
index 15aa86a..8c52e08 100644
--- a/docs/features/account-and-devices.md
+++ b/docs/features/account-and-devices.md
@@ -57,7 +57,7 @@ A device signed out from elsewhere learns it on its next token refresh: `401 dev
4. drops the sync session and what the last rounds learnt (`SyncState::forget`);
5. emits `device-erased` with `{reason, email}`, and the window returns to the first screen with:
-> This device was signed out of {email} from another device, so MorphGate removed everything it kept here. To use the account again, sign in under “I have an account”.
+> This device was signed out of {email} from another device, so MorphGate removed everything it kept here. To use the account again, choose “Sign in”.
The spec's reason: the account signed this device out, so the device forgets what it keeps from the account. Every other `401` on refresh only means "sign in again" and keeps the data; see [Sync](sync.md#tokens).
@@ -72,7 +72,7 @@ The spec's reason: the account signed this device out, so the device forgets wha
Both first end the session with `POST /v1/auth/logout` when one is open, ignoring errors: offline, the session stays on the server until it expires or another device signs this one out.
-- **Remove everything** runs `erase_device(app, "disconnected")` as above, and the first screen says: "MorphGate removed everything it kept on this device. Your vaults are still in {email}: sign in under “I have an account” to get them back." Unsynced changes are lost.
+- **Remove everything** runs `erase_device(app, "disconnected")` as above, and the first screen says: "MorphGate removed everything it kept on this device. Your vaults are still in {email}: choose “Sign in” to get them back." Unsynced changes are lost.
- **Keep a copy** runs `disable_sync()`: it deletes the sync settings and token, the team vaults and organizations (they belong to the team on the server) and tombstones, and marks every remaining item unsynced (`rev` 0, dirty) with every vault's `head_rev` back to 0. Unsynced changes stay. Turning sync on again, on any server, uploads the whole personal vault.
Moving to another server: on one device **Keep a copy here**, then **Enable sync** with the new address. Other devices either remove everything and sign in on the first screen, or keep a copy and use **I have an account** in the Sync panel; their items join the account's vault with the same ids, so identical items merge and differing ones become "(conflict)" copies ([Sync](sync.md#conflicts)).
@@ -97,7 +97,7 @@ The master password and the recovery key are the account's, so with sync on the
| Change the master password (Security) | `vault_change_password` | `account::change_password`: checks the current password here and prepares new KDF parameters, AuthKey and wrapped keys, sends `PUT /v1/account/keys {current_auth_key, new_kdf, new_auth_key, wrapped_private_keys}`, then applies them here. The server signs the other devices out; each shows **Sign in again** and, with the new password, re-wraps its own keys (`sign_in_again`, `adopt_credentials`) |
| New recovery key (Security) | `vault_new_recovery_code` | `account::new_recovery_key`: the same request with the password unchanged and a new `recovery {wrapped_private_keys_rk, recovery_proof}`. Nobody is signed out; the old code stops working on the server too |
| Forgot the password, on a synced device (unlock screen) | `vault_recover` | `account::recover`: `POST /v1/account/recover {email, recovery_proof}` returns the recovery-wrapped keys and a short-lived token that only allows `PUT /v1/account/keys`; the new credentials are made here from the recovery code and sent without a current AuthKey; then the device signs in again with the new password and its own device id, without a code (recovery turns two-step verification off), and only then stores them. Every other device is signed out |
-| Forgot the password, fresh device (**I have an account**, **Forgot the master password? Use your recovery key**) | `sync_recover_sign_in` | `account::recover_and_sign_in`: the same on the server, then an ordinary sign-in with the new password ([Sync](sync.md#signing-in)) |
+| Forgot the password, fresh device (**Sign in**, **Forgot the master password? Use your recovery key**) | `sync_recover_sign_in` | `account::recover_and_sign_in`: the same on the server, then an ordinary sign-in with the new password ([Sync](sync.md#signing-in)) |
A recovery code the server doesn't accept is "the sync server doesn't accept this recovery code for that account". While signed out, account changes stop at "this device is signed out of sync; sign in again (Sync panel) before changing the account". Server side: `PUT /v1/account/keys` and `POST /v1/account/recover` in `spec/openapi.yaml`, and the single-use recovery token in the [spec](../sync-crypto-spec.md#auth--devices).
@@ -148,7 +148,17 @@ The account's other devices hear `401 account_deleted` on their next refresh (a
## When an organization holds this device
-An organization can keep its vaults from a device until something is settled. Its vaults that are already here stay, unsynced edits included, and sync once it is settled; the personal vault is never affected. The shared part of each round notes the hold (`Hold` in `crates/mg-store/src/sync.rs`) from a `403 sso_required` or `403 device_approval_required` on the organization's vaults, and, with device approval on, from this device's row in `GET /v1/orgs/{org}/devices`. `SyncStatusView` carries them as `ssoRequired` and `approvalRequired`. The team side (approving others, policies) is in [Teams](teams.md#device-approval-single-sign-on-and-activity).
+An organization can keep its vaults from a device until something is settled. Its vaults that are already here stay, unsynced edits included, and sync once it is settled; the personal vault is never affected. The shared part of each round notes the hold (`Hold` in `crates/mg-store/src/sync.rs`) from a `403 totp_setup_required`, `403 sso_required` or `403 device_approval_required` on the organization's vaults, and, with device approval on, from this device's row in `GET /v1/orgs/{org}/devices`. A server leaves such vaults out of `GET /v1/vaults` without saying why, so a member who never had them would see nothing: for an organization with `require_totp` none of whose vaults is listed, the round reads `GET /v1/account` and notes the hold when `totp_enabled` is off. `SyncStatusView` carries them as `totpRequired`, `ssoRequired` and `approvalRequired`. The team side (approving others, policies) is in [Teams](teams.md#device-approval-single-sign-on-and-activity).
+
+### Two-step verification required
+
+| Where | Shows |
+| --- | --- |
+| Sidebar foot | **Turn on two-step verification**; its tooltip names the teams: "{team} requires two-step verification: its vaults open here once your account has it" |
+| Phone, hosts screen | **Turn on two-step verification** with the same line |
+| Team page | "{team} requires two-step verification: its vaults open on your devices once your account has it." and **Turn on two-step verification** |
+
+Each opens Sync with the authenticator setup started (`{kind: "sync", totp: true}`), under the same line in a notice. `sync_totp_enable` and `sync_totp_disable` start a round when they're done, so the team's vaults come, or wait, at once.
### Waiting for approval
@@ -214,6 +224,7 @@ All of them hold the sync lock, and those that keep the session store a rotated
| `server/tests/account.rs` | `signing_out_another_device` (the other device gets `DeviceRevoked`, signs in again), `a_device_updates_what_it_says_about_itself` (and can't rename another, nor to blank), `deleting_an_account_tells_its_other_devices` (`AccountDeleted`, and `DeviceRevoked` for one signed out before), `the_data_export_tells_what_the_server_keeps` |
| `server/tests/account.rs`, `protocol.rs` | `password_change_reaches_every_device`, `recovery_on_a_synced_device_and_on_a_fresh_one`, `a_new_recovery_key_replaces_the_old_one_without_signing_anyone_out`, `password_rotation_signs_out_others_but_a_new_recovery_key_does_not`, `recovery_tokens_are_narrow_and_single_use` |
| `server/tests/device_approval.rs` | `a_new_device_waits_for_approval` |
+| `server/tests/totp_policy.rs` | `a_team_that_requires_two_step_verification_says_so`: a proxy in front of the reference server plays the hosted service's check; a new member without it is told, gets the vault once it's on, and keeps it (told again) when it's off |
| `crates/mg-store/src/sync.rs`, `vault.rs` | `token_and_config_roundtrip_and_disable` (what **Keep a copy here** leaves), `erase_leaves_a_first_run_device` |
| `apps/desktop/e2e/teams.mjs` | Bob signs his second device out from his first: it erases itself, the first screen says why, and signing in brings his vault back; a new device waits for approval, is denied, then approved from Bob's first device; Alice signs in to her team's identity provider from **Sign in to {team}**; **Download your data**; **Delete account…** sends an owner to Teams, deletes Bob's account, and his second device says the account was deleted |
diff --git a/docs/features/cloud-sources.md b/docs/features/cloud-sources.md
index 63c7d36..e117895 100644
--- a/docs/features/cloud-sources.md
+++ b/docs/features/cloud-sources.md
@@ -1,6 +1,6 @@
# Cloud sources
-A cloud source keeps a set of hosts in step with a provider: Vallic Cloud's environments, or the virtual machines of an AWS, Google Cloud, Azure, Hetzner Cloud or DigitalOcean account. A refresh brings what the cloud decides (which machines exist, their addresses, whether they run); what the person sets stays (renames, keys, schemes, tags, notes, groups); a machine the cloud no longer lists is marked gone, never deleted. The vault holds no cloud credentials: each device signs in to Vallic Cloud itself, and lists the other clouds with their own CLI as that device has it set up, so the calls go from the device to the provider and nothing reaches MorphGate's server but the encrypted items. Code: `crates/mg-inventory` (`lib.rs`: `InventoryHost`, `Machine`; `vallic.rs`, `cli.rs`, `aws.rs`, `gcp.rs`, `azure.rs`, `hetzner.rs`, `digitalocean.rs`, `shape.rs`), `crates/mg-store/src/model.rs` (`CloudSource`, `HostSource`, `CloudReach`), `apps/desktop/src-tauri/src/cloud.rs` (Tauri commands, `apply()`, `reach_command()`), `apps/desktop/src/CloudPage.tsx`.
+A cloud source keeps a set of hosts in step with a provider: Vallic Cloud's environments, the virtual machines of an AWS, Google Cloud, Azure, Hetzner Cloud or DigitalOcean account, or a feed (a JSON document of hosts and snippets that a team publishes at an address). A refresh brings what the provider decides (which machines exist, their addresses, whether they run); what the person sets stays (renames, keys, schemes, tags, notes, groups); a machine the provider no longer lists is marked gone, never deleted. The vault holds no cloud credentials: each device signs in to Vallic Cloud itself, and lists the other clouds with their own CLI as that device has it set up, so the calls go from the device to the provider and nothing reaches MorphGate's server but the encrypted items. A feed's token is the one exception ([Feeds](#feeds)). Code: `crates/mg-inventory` (`lib.rs`: `InventoryHost`, `Machine`; `vallic.rs`, `cli.rs`, `aws.rs`, `gcp.rs`, `azure.rs`, `hetzner.rs`, `digitalocean.rs`, `shape.rs`, `feed.rs`), `crates/mg-store/src/model.rs` (`CloudSource`, `HostSource`, `CloudReach`, `SnippetSource`), `apps/desktop/src-tauri/src/cloud.rs` (Tauri commands, `apply()`, `apply_snippets()`, `reach_command()`), `apps/desktop/src/CloudPage.tsx`.
## Providers
@@ -12,6 +12,7 @@ A cloud source keeps a set of hosts in step with a provider: Vallic Cloud's envi
| `azure` | `az` | `az login` | Resource group | The VM's admin user, else `azureuser` | Public, private | `azure:` |
| `hetzner` | `hcloud` | The CLI's context | Location | `root` | Public, private | `hetzner:/` |
| `digitalocean` | `doctl` | The CLI's context | Region | `root` | Public, private | `digitalocean:` |
+| `feed` | `GET` of its URL | The source's token, in the vault | The feed's `group`, else none | The feed's | Address, through the feed's own jump hosts | `feed:` |
## The items
@@ -19,7 +20,7 @@ A `cloud_source` item (in a personal or team vault):
| Field | Meaning |
| --- | --- |
-| `label`, `provider` | "Vallic Cloud", "AWS (prod)", "Hetzner Cloud (shop)"…; one of the six above (else "a cloud source's provider is vallic, aws, gcp, azure, hetzner or digitalocean") |
+| `label`, `provider` | "Vallic Cloud", "AWS (prod)", "Hetzner Cloud (shop)", a feed's name…; one of the seven above (else "a cloud source's provider is vallic, aws, gcp, azure, hetzner, digitalocean or feed") |
| `endpoint` | Vallic's console when not `https://console.vallic.com` |
| `account` | Who signed in when it was made (an email, an ARN, `user (subscription)`, the context) |
| `key_ref` | The `ssh_key` its new hosts sign in with; none: the SSH agent |
@@ -28,8 +29,9 @@ A `cloud_source` item (in a personal or team vault):
| `scopes` | AWS regions, Google Cloud projects or Azure subscription ids; empty: every enabled region, active project or enabled subscription the sign-in sees |
| `reach` | None (public addresses), `private` (with `jump_ref`: "private addresses need a jump host"), `aws-ssm`, `aws-eice` (AWS only), `gcp-iap` (Google Cloud only) |
| `jump_ref`, `user`, `group_tag` | The jump host for private addresses; who to log in as where a machine doesn't say; group by this tag's or label's value instead |
+| `url`, `token` | A feed's address (`https://`, or `http://` on `localhost`, `127.0.0.1` or `[::1]`: else "a feed's address starts with https://") and the token it's read with (required: "a feed needs the token it's read with") |
-A host it keeps carries `source` (`HostSource`): `source_ref`, `external_id` (what a refresh matches on), `label` (the provider's name for it: a host still called that follows renames, one renamed in MorphGate keeps its name), `gone`, `no_sftp`, `no_forwarding`, `state` (`stopped`, `VM deallocated`…) and `reach`. Lists show "gone from AWS" or the state as a badge, and hide **Files** for `no_sftp` ([Hosts, groups and identities](hosts-groups-identities.md)). For `no_forwarding` the tunnel editor leaves the host out, saving or starting a tunnel through it is refused ("\"{host}\" can't carry tunnels: its cloud's SSH doesn't forward ports"), and the agent isn't forwarded to it ([Port forwarding](port-forwarding.md)).
+A host it keeps carries `source` (`HostSource`): `source_ref`, `external_id` (what a refresh matches on), `label` (the provider's name for it: a host still called that follows renames, one renamed in MorphGate keeps its name), `gone`, `no_sftp`, `no_forwarding`, `state` (`stopped`, `VM deallocated`…), `reach`, and from a feed `host_keys` (its server's keys) and `tags` (the ones the feed gave it, among the host's). A feed's snippet carries `source` (`SnippetSource`: `source_ref`, `external_id`). Lists show "gone from AWS" ("gone from its feed") or the state as a badge, and hide **Files** for `no_sftp` ([Hosts, groups and identities](hosts-groups-identities.md)). For `no_forwarding` the tunnel editor leaves the host out, saving or starting a tunnel through it is refused ("\"{host}\" can't carry tunnels: its cloud's SSH doesn't forward ports"), and the agent isn't forwarded to it ([Port forwarding](port-forwarding.md)).
## Where credentials live
@@ -38,12 +40,13 @@ A host it keeps carries `source` (`HostSource`): `source_ref`, `external_id` (wh
| Vallic Cloud sign-in | The rotating refresh token, as a device secret: setting `secret:cloud:` in `settings`, sealed with the personal vault's key (readable only while the vault is unlocked), never synced. The access token stays in memory (15 minutes, renewed a minute early) |
| AWS, Google Cloud, Azure, Hetzner, DigitalOcean | The CLI's own configuration, untouched. MorphGate asks for no keys: keys kept in the vault would put long-lived cloud credentials in every synced copy, and each provider's single sign-on would have to be done again in MorphGate |
| The CLI's path, when it isn't found | Setting `cloud-cli:`, on this device |
+| A feed's token | The `cloud_source` item's `token`, synced with the vault ([Feeds](#feeds)) |
Refresh tokens rotate and a replayed one ends the sign-in, so `CloudState` lets one cloud operation run at a time per device, and a rotated token is stored before anything else can fail. MorphGate signs in to Vallic as the public client `vallic-cli` (`CLIENT_ID`), naming the session "MorphGate on ".
## Adding a source
-The Hosts page's **Clouds…** (desktop) opens "Clouds": each source, then a **Vault** choice and **Connect Vallic Cloud** (**Connect another Vallic account** once there is one), **Add AWS**, **Add Google Cloud**, **Add Azure**, **Add Hetzner Cloud**, **Add DigitalOcean**.
+The Hosts page's **Add from…** → **Clouds…** (desktop) opens "Clouds": each source, then a **Vault** choice and **Connect Vallic Cloud** (**Connect another Vallic account** once there is one), **Add AWS**, **Add Google Cloud**, **Add Azure**, **Add Hetzner Cloud**, **Add DigitalOcean**, **Add a feed**. **Add from…** → **Feed URL…** opens it at **Add a feed**.
### Vallic Cloud
@@ -95,18 +98,104 @@ How a machine becomes a host (`shape::hosts()`):
| Sign-in (new hosts) | AWS: the vault key labelled like the instance's key pair; else the source's key; else the system's SSH agent |
| Left out | Windows machines, terminated EC2 instances; running machines without the address the source uses, and new stopped ones without any ("Without a public address: …") |
+### A feed
+
+**Add a feed**: "Hosts and snippets from a JSON document at an address, read with a token: …", the **Vault**, then:
+
+| Control | Field |
+| --- | --- |
+| **Address** (placeholder `https://ops.example.com/morphgate.json`) | `url` |
+| **Token** (a password field; "Kept in the vault, so every device that can open it refreshes the feed. Use a token that only reads the feed.") | `token` |
+| **Name (optional)** | `label`: else what the feed calls itself (`name`), else the address's host |
+| **Its hosts sign in with** | `key_ref` |
+| **Add and read the feed** ("Reading…"), **Cancel** | `cloud_feed_add`: the feed is read before the source is kept, so a wrong address or token is said and nothing is made ("{host} refused the token") |
+
+The source's card shows the address and the token's last 4 characters ("token …f3a9"; "…" for a token under 16 characters), never the rest. **Settings…** opens the same form (an empty **Token**, "Leave empty to keep it (…f3a9)", keeps it), then **Save and refresh** (`cloud_feed_settings`, then `cloud_refresh`).
+
## Refresh
-`cloud_refresh(source_id)` runs on demand: **Refresh** on the source, or after its settings change. `apply()` matches the source's hosts in its vault by `external_id`:
+`cloud_refresh(source_id)` runs on demand: **Refresh** on the source, or after its settings change; a feed also refreshes itself ([Feeds](#automatic-refresh)). `apply()` matches the source's hosts in its vault by `external_id`:
| What | On refresh |
| --- | --- |
-| Follows the provider | Label (until renamed here), host name (a stopped machine keeps its last one), port, user, login and jump commands, the jump host (the source's for private addresses, else none; Vallic's front), the `source` marker |
-| Stays the person's | Sign-in (key, identity), terminal scheme, VPN, tags, notes, group, agent forwarding, recording, session keeping |
-| A new machine | A new host, in the group of that name (made when missing) |
+| Follows the provider | Label (until renamed here), host name (a stopped machine keeps its last one), port, user, login and jump commands, the jump host (the source's for private addresses, else none; Vallic's front; a feed's `jump`), the `source` marker (a feed's host keys and state with it) |
+| Stays the person's | Sign-in (key, identity), terminal scheme, VPN, tags, notes, group, agent forwarding, recording, session keeping. A feed's tags are added: the ones it gave before are replaced by the ones it gives now, the person's stay |
+| A new machine | A new host, in the group of that name (made when missing; none where a feed names none) |
| No longer listed | `gone` set; listed again, it clears |
-The page says "2 hosts added, 1 updated, 1 gone from the cloud" (or "Nothing changed"), then what it skipped: "No shell for you (your role), or not running yet: …" (Vallic), "Without a public address: …", "Couldn't list: …" (regions, projects or subscriptions that refused, and machines whose ids aren't shaped like the cloud's).
+New items get ids worked out rather than drawn (`mg_store::derived_id`: SHA-256 of the vault, the source, the kind and the `external_id`, or the group's name in lowercase, as a ULID; `LocalVault::insert_in`): two devices refreshing a source at once make the same host, group or snippet under the same id, and the second gets a conflict instead of a duplicate. Copies that differ only in what the source decides (the source's `refreshed_at` too) make no "(conflict)" copy ([Sync](sync.md#conflicts)). A live item already holding the id (one that lost its `source`) leaves the new one a random id.
+
+The page says "2 hosts added, 1 updated, 1 gone from the cloud" ("…gone from the feed"; a feed adds "1 snippet added", "1 snippet changed", "1 snippet removed"), or "Nothing changed", then what it skipped: "No shell for you (your role), or not running yet: …" (Vallic), "Without a public address: …", "Couldn't list: …" (regions, projects or subscriptions that refused, and machines whose ids aren't shaped like the cloud's), "Skipped: …" (a feed's entries). A refresh that failed on this device (an automatic one included) shows on the card until one succeeds: "The last refresh failed: …" (`CloudState`, in memory).
+
+## Feeds
+
+A feed is a JSON document of hosts and snippets at an address, read with a token: generated from Terraform output, a CMDB or a cron job, or served by an internal endpoint. It's how a team brings in what no cloud's API shows (an operator port, a staff user, bastions). The device reads it straight from the publisher over HTTPS, and the result becomes ordinary vault items, so MorphGate's server sees nothing new. `mg_inventory::feed` reads it (`fetch`, `parse`): data only, never something that runs on the device (no proxy or local command, no `CloudReach`).
+
+### The request
+
+`GET ` with `Authorization: Bearer `, `Accept: application/json` and `User-Agent: MorphGate/`. Redirects are followed (5 at most) only to `https://` or this computer. The answer is read up to 5 MiB, within 60 s.
+
+| Answer | Message |
+| --- | --- |
+| 401 or 403 | "{source} refused the token: change it in the source's settings" |
+| Another status | "{source} answered {status}" |
+| Over 5 MiB | "{source} sent more than 5 MiB: a feed is at most that" |
+| Over 60 s | "{source} took longer than 60 s to answer" |
+| Not JSON, not an object, no `"version": 1`, `hosts` or `snippets` not a list | "{source} sent something this version can't read: …" |
+| `version` above 1 | "{source} is a version {n} feed: update MorphGate to read it" |
+| More than 5,000 hosts or 1,000 snippets | "{source} lists {n} hosts: a feed holds at most 5,000" |
+| Unreachable | "can't reach {source}: …" |
+
+A document refused whole changes nothing.
+
+### The format (version 1)
+
+```json
+{
+ "version": 1,
+ "name": "Ops",
+ "hosts": [
+ { "id": "bastion-fsn1", "label": "Bastion FSN1", "hostname": "bastion-fsn1.example.net",
+ "port": 22, "user": "ops", "group": "Bastions", "host_keys": ["ssh-ed25519 AAAAC3Nza..."] },
+ { "id": "server-1234", "label": "acme-prod-1", "hostname": "10.0.12.34", "port": 2222,
+ "user": "ops", "group": "Acme", "tags": ["production", "fsn1"], "jump": "bastion-fsn1",
+ "key": "customers", "host_keys": ["ssh-ed25519 AAAAC3Nza..."], "state": "running" }
+ ],
+ "snippets": [
+ { "id": "php-errors", "name": "PHP error log", "body": "podman logs --tail 200 php", "tags": ["diagnostics"] }
+ ]
+}
+```
+
+| Field | Rule |
+| --- | --- |
+| `version` | `1` |
+| `name` | Optional: what the source is called when it's added without a name |
+| `id` | Required, unique among the hosts (and among the snippets): 1 to 200 of `A-Z a-z 0-9 . _ : / @ -`. What a refresh matches on, as `external_id` `feed:` |
+| `hostname`, `user` | Required: no whitespace or control characters, no leading `-`, at most 255 bytes |
+| `label` | Optional (control characters out, at most 200 characters); else the `id` |
+| `port` | 1 to 65535; 22 when left out |
+| `group` | Optional: the group a new host goes in (made when missing); none when left out |
+| `tags` | Optional: added to the host's (at most 50, 64 characters each) |
+| `jump` | Optional: another host's `id` in the same feed, kept, not itself, and not leading round in a circle |
+| `key` | Optional: the label of an SSH key in the vault that a new host signs in with (as an EC2 key pair's name). No match: the source's key (**Its hosts sign in with**), else the SSH agent. Never a key itself |
+| `host_keys` | Optional, at most 16: the server's OpenSSH public keys (`ssh-ed25519`, `ecdsa-sha2-nistp256/384/521`, `ssh-rsa`, `sk-…`; type and base64, a comment after them dropped). They decide the host's key at connect time instead of trust on first use; another key is refused ([SSH connections](ssh-connections.md#keys-a-feed-lists)). Left out, the first connection asks as usual |
+| `state` | Optional: shown as a badge (`stopped`); `running` shows none |
+| `snippets[].id`, `name`, `body`, `tags` | `id` as for hosts; `name` and `body` required; the body at most 64 KiB, with no control characters but newlines and tabs (a terminal reads escape sequences as more than text) |
+
+Unknown fields are ignored and `null` reads as left out. An entry that breaks a rule is skipped and said ("Skipped: server-9: the port isn't 1 to 65535; …"); a host that jumps through a skipped one is skipped too.
+
+### Snippets
+
+A feed's snippets follow it whole: each refresh sets their name, body and tags, makes new ones and deletes the ones it no longer lists. They're read-only in the app (**Make a copy** to change one), and **Run** and **Paste** ask first when the feed changed one since this device last sent it ([Terminal and snippets](terminal-and-snippets.md#a-feeds-snippets)).
+
+### The token
+
+Every device of the team refreshes, so the token is in the `cloud_source` item, synced to everyone who can open the vault: an exception to the vault holding no cloud credentials, taken because a feed's token only reads a host list. Publishers should issue one that does only that. The card shows its last 4 characters at most. Offboarding lists each feed in the member's vaults under what to change: "Rotate the token for {source}" ([Offboarding](offboarding.md)).
+
+### Automatic refresh
+
+On desktops the app calls `cloud_auto_refresh` once the first sync since unlocking has finished (at once without sync, after 20 s at the latest), then every 15 minutes while unlocked. It refreshes, one at a time, each feed in a vault this device can write to whose `refreshed_at` (synced) is 10 minutes old or more, checking again just before (a sync may have brought another device's refresh). Members who can only view never refresh. The CLI providers and Vallic Cloud still refresh on demand only.
## Reaching machines through the cloud
@@ -125,9 +214,10 @@ The CLI's path is quoted for the shell with `%` doubled; a profile must be lette
| Control | Command | Does |
| --- | --- | --- |
| **Its hosts sign in with** | `cloud_source_key` | Sets the source's key; its hosts that used the previous key (or the system agent, when there was none) follow, others keep theirs |
-| **Settings…** (CLI sources) | `cloud_source_settings` | Changes what is listed and how; Vallic: "Vallic Cloud lists what your sign-in reaches" |
+| **Settings…** (CLI sources) | `cloud_source_settings` | Changes what is listed and how; Vallic: "Vallic Cloud lists what your sign-in reaches"; a feed: "a feed's settings are its address and token" |
+| **Settings…** (feeds) | `cloud_feed_settings` | Its name, address and token |
| **Sign out on this device** (Vallic) | `cloud_sign_out` | Revokes the token on the console and forgets it here; the source and hosts stay |
-| **Remove…**, then **Remove it and its hosts** or **Remove it, keep the hosts** | `cloud_source_delete(source_id, delete_hosts)` | Signs out, deletes the hosts (those behind a jump host first) or makes them ordinary hosts, then the source. Groups it made stay |
+| **Remove…**, then **Remove it and its hosts** or **Remove it, keep the hosts** (a feed with snippets: **Remove it, its hosts and snippets**, **Remove it, keep the hosts and snippets**) | `cloud_source_delete(source_id, delete_hosts)` | Signs out, deletes the hosts (those behind a jump host first) and a feed's snippets, or makes them ordinary hosts and snippets, then the source. Groups it made stay. Deleting the item while a host or snippet still names it is refused, as for anything in use |
## Running the CLIs
@@ -146,31 +236,35 @@ The CLI's path is quoted for the shell with `%` doubled; a profile must be lette
| Command | Does |
| --- | --- |
-| `cloud_sources` | Sources as `SourceView`: host and gone counts, `signed_in` (Vallic: this device has a sign-in; CLI sources: always), the CLI path set here |
+| `cloud_sources` | Sources as `SourceView`: host, gone and snippet counts, `signed_in` (Vallic: this device has a sign-in; the others: always), the CLI path set here, a feed's `url` and `token_end`, and `failed` (the last refresh here, when it failed) |
| `vallic_sign_in(endpoint)`, `vallic_sign_in_poll(endpoint, device_code, source_id, vault_id)` | The device sign-in; `Pending {slower}`, `Denied`, `Expired`, or `Done {source, refresh}` |
| `cloud_source_add(provider, vault_id, key_id, settings)`, `cloud_source_settings(source_id, settings)` | A CLI source; its settings |
| `cloud_check(provider, profile)`, `cloud_cli_path(provider, path)` | Who the CLI is signed in as; where this device runs it from |
-| `cloud_refresh(source_id)` | `RefreshResult {added, updated, gone, without_shell, without_address, skipped}` |
+| `cloud_feed_add(vault_id, key_id, {label, url, token})`, `cloud_feed_settings(source_id, {label, url, token})` | A feed (read first, then kept and brought in); its name, address and token (an empty token keeps it) |
+| `cloud_refresh(source_id)` | `RefreshResult {added, updated, gone, without_shell, without_address, skipped, snippets_added, snippets_updated, snippets_removed}` |
+| `cloud_auto_refresh` | The feeds that are due ([Automatic refresh](#automatic-refresh)); how many it refreshed. Nothing on phones |
| `cloud_source_key`, `cloud_sign_out`, `cloud_source_delete`, `cloud_add_key(source_id, key_id)` | As above |
## Platform differences
| Where | Difference |
| --- | --- |
-| Phones and tablets | No **Clouds…** page and no CLIs. The hosts sync and connect, except those reached through the cloud: "\"web-1\" connects through its cloud's CLI on the computer: connect to it from the desktop app" |
+| Phones and tablets | No **Clouds…** page and no CLIs, and feeds don't refresh themselves. The hosts sync and connect (a feed's host keys apply there too), except those reached through the cloud: "\"web-1\" connects through its cloud's CLI on the computer: connect to it from the desktop app" |
| Windows | CLIs found as `.exe` or `.cmd` |
## Tests
- `crates/mg-inventory/src/*.rs`: each provider's JSON as its CLI documents it, `shape.rs` (addresses, users, groups by tag, Session Manager), `vallic.rs` (environments as hosts), `cli.rs` (JSON read, stderr passed on, a missing CLI named). `crates/mg-store/src/model.rs`: `only_what_the_clouds_ids_look_like`. `apps/desktop/src-tauri/src/cloud.rs`: private machines and stopped ones, the reach command built here, a refresh following the platform and keeping what is the person's.
- `crates/mg-inventory/tests/console.rs`: the Vallic client against a fake console on loopback (device flow, rotation, a replayed token refused, the inventory).
+- `crates/mg-inventory/src/feed.rs`: the format's example, defaults, every rule's skip and its message, documents refused whole, `https://` only. `crates/mg-inventory/tests/feed.rs`: the feed client against a fake publisher on loopback (the token and headers sent, a refused token, over 5 MiB, a redirect to plain HTTP not followed). `apps/desktop/src-tauri/src/cloud.rs`: `a_feed_keeps_hosts_and_snippets_in_step` (derived ids, jump hosts, groups, tags kept, host keys, snippets dropped). `crates/mg-store/src/sync.rs`: `refreshes_on_two_devices_make_no_copies`. `crates/mg-ssh/tests/live.rs`: `listed_host_keys_decide`.
- `crates/mg-inventory/tests/clis.rs`: the real CLIs, read only, printing counts only; ignored unless asked, e.g. `MG_TEST_AWS=1 [MG_TEST_AWS_PROFILE=…] cargo test -p mg-inventory --test clis -- --include-ignored --nocapture` (also `MG_TEST_AZ`, `MG_TEST_GCLOUD`, `MG_TEST_HCLOUD`, `MG_TEST_DOCTL`).
-- e2e `apps/desktop/e2e/appimage.mjs`: Vallic Cloud through `vallic-console.mjs` (sign-in approved on the second poll, the key added to the account, no Files); AWS through the stand-in `e2e/clouds/aws` (an instance that is the test sshd, signing in with the vault key named like its key pair, a stopped one waiting, then the same source through Session Manager, which the stand-in relays with `crates/mg-ssh/tests/relay.py`); DigitalOcean through `e2e/clouds/doctl`. `run.sh` sets `MORPHGATE_VALLIC_CONSOLE`, `MORPHGATE_CLOUD_CLI_DIR`, `E2E_CLOUD_PORT` and `E2E_CLOUD_USER`.
+- e2e `apps/desktop/e2e/appimage.mjs`: Vallic Cloud through `vallic-console.mjs` (sign-in approved on the second poll, the key added to the account, no Files); AWS through the stand-in `e2e/clouds/aws` (an instance that is the test sshd, signing in with the vault key named like its key pair, a stopped one waiting, then the same source through Session Manager, which the stand-in relays with `crates/mg-ssh/tests/relay.py`); DigitalOcean through `e2e/clouds/doctl`; a feed through `e2e/host-feed.mjs` (a wrong token said, its host on 127.0.0.4 connecting with the listed key and no prompt, one listed with another key refused, an invalid entry skipped, its snippet read-only and asking before it runs once the feed changed it). `run.sh` sets `MORPHGATE_VALLIC_CONSOLE`, `MORPHGATE_CLOUD_CLI_DIR`, `E2E_CLOUD_PORT`, `E2E_CLOUD_USER`, `E2E_HOST_FEED` and `E2E_HOST_FEED_TOKEN`.
## Limits
-- Refreshing is on demand only, per source, on a desktop.
+- Refreshing is on demand only, per source, on a desktop; feeds also refresh themselves there.
- Vallic Cloud: a source brings every project the sign-in reaches, grouped by project; no SFTP; its database and cache tunnels (`vallic-tunnel`) aren't offered, and neither are ordinary tunnels or agent forwarding (`no_forwarding`).
- Not supported: Azure Bastion (`az network bastion tunnel` listens on a port and can't carry a connection on stdin), Azure's Entra ID sign-in to VMs (`az ssh cert`). Windows machines are left out.
- A jump host set by hand on a kept host is replaced at the next refresh.
-- Moving a source to another vault doesn't take its hosts (they don't ride with it); its next refresh there makes new ones.
+- Moving a source to another vault doesn't take its hosts or snippets (they don't ride with it); its next refresh there makes new ones.
+- A feed can't carry a login command or reach a host through a command (a remote login command may come later); ETags aren't sent yet, so each refresh reads the whole document.
diff --git a/docs/features/deep-links.md b/docs/features/deep-links.md
index a11bad6..ae03f99 100644
--- a/docs/features/deep-links.md
+++ b/docs/features/deep-links.md
@@ -41,7 +41,7 @@ A refused link shows a banner: "MorphGate can't open this link: {reason}".
| Another scheme (`https://…`) handed to the app | Dropped silently (`received()` keeps only its own schemes) | |
| `connect` | Fills in host, port, user and label only. The person picks how to sign in, and the host key is checked as for any connection | Connecting is the person's decision; a link could point at a look-alike host |
| Host or user with other characters (`a;rm -rf`, `root;id`) | Refused | Only names and addresses get through, never shell syntax or options |
-| `invite` while sync is off or signed out | Notice "This is an invite to a team on {server}. Turn on sync with the invited address first, then open the link again." and the Sync page opens | An invite is accepted by a signed-in account |
+| `invite` while sync is off or signed out | Notice "This is an invite to a team on {server}. Turn on sync with the invited address: Teams opens with the invite once it's on." and the Sync page opens; once sync is on, Teams opens with it | An invite is accepted by a signed-in account |
| `invite` whose `server` is not the server this device syncs with | Notice "This invite is for a team on {server}, but this device syncs with {other}."; nothing opens | Invite codes are only valid on the server that made them |
Joining still needs the fingerprint check and a click on **Join {team}**; quick connect still needs **Connect**. [Trust boundaries](../architecture.md#trust-boundaries) lists links among the app's inputs.
@@ -51,11 +51,13 @@ Joining still needs the fingerprint check and a click on **Join {team}**; quick
1. The operating system hands the URL to `tauri-plugin-deep-link`: at launch (`get_current()` in setup) or while running (`on_open_url`).
2. On Windows and Linux a link starts a second copy of the app. `tauri-plugin-single-instance` (with its `deep-link` feature) passes the URL to the copy already running for this user's vault and the second copy exits. On macOS and the phones the system delivers the URL to the running app.
3. `links::received()` parses each URL into a `Received {link, error}`, appends it to the `Links` queue, brings the window forward on desktop (`show()`: unminimize, show, focus) and emits `links-pending`.
-4. The unlocked workspace calls `links_take` when it mounts and on every `links-pending`, and opens each link (`openLink` in `App.tsx`). While the vault is locked nothing takes them, so they wait in the queue until unlock.
+4. The unlocked workspace calls `links_take` once it knows whether sync is on, and on every `links-pending`, and opens each link (`openLink` in `App.tsx`). While the vault is locked nothing takes them, so they wait in the queue until unlock. Before there's a vault, the first screen shows a waiting invite without taking it ([First run](vault-and-unlock.md#first-run)).
| Command or event | Does |
| --- | --- |
| `links_take` | Returns and empties the queue of received links |
+| `links_peek` | Returns the queue, leaving it as it is |
+| `links_add_invite` | An invite pasted on the first screen: its code, `https://…/invite/{code}`, or a `morphgate://invite` link (`pasted_invite`). Replaces an invite already waiting |
| `links-pending` (event) | A link arrived; the workspace takes it |
## Registering the scheme
diff --git a/docs/features/hosts-groups-identities.md b/docs/features/hosts-groups-identities.md
index 4825d54..339dc6d 100644
--- a/docs/features/hosts-groups-identities.md
+++ b/docs/features/hosts-groups-identities.md
@@ -18,7 +18,7 @@ An item is an `ItemData` serialized as JSON with a `type` tag (`{"type": "host",
| `vpn` | A VPN profile a host can go through | [VPN](vpn.md) |
| `terminal_theme` | Terminal colors | [Appearance](appearance.md) |
| `session_log` | A recorded session (blobs) | [Session logs](session-logs.md) |
-| `cloud_source` | Hosts kept in step with a cloud | [Cloud sources](cloud-sources.md) |
+| `cloud_source` | Hosts kept in step with a cloud or a feed | [Cloud sources](cloud-sources.md) |
| `member_key` | A member's published SSH public key; only in an organization's directory vault | [Teams](teams.md) |
| unknown | A kind a newer app wrote: kept and synced, left out of lists, never edited or moved | |
@@ -106,19 +106,27 @@ An `attachment` is `name` (as added, without a path), `host_ref`, `blob` (hex SH
The sidebar (`Sidebar.tsx`) is for connecting; the pages (`Lists.tsx`) are for managing.
+Every list page is a table (`Table`, `TRow`, `NameCell`): a row per item, its name first, which is the row's main action (connect a host, open anything else), then the columns people scan, then the row's other actions, shown on hover or keyboard focus (always on touch screens). Every other row carries the greenbar band. A page has one primary button; Close is a quiet × at the top right. A toolbar holds the search, filters (a page's most used tags, "Connected" or "Running") and **Select**. Below 640 px of page width the rows stack and their actions stay shown.
+
+On desktop, once the vault has hosts, the Hosts page is home: the workspace opens on it after unlocking, and closing the last tab or page returns to it (`home()` in `App.tsx`). With no hosts, home is quick connect. Closing the Hosts page itself goes to quick connect. On phones the start screen is the host list either way.
+
| Sidebar part | What it does |
| --- | --- |
| Navigation | **Hosts**, **Groups**, **Keys** (keys and identities counted together), **Snippets**, **Tunnels** ("2 on"), **VPNs** ("1 up"), and **Session logs** once a host records |
| Search | "Search hosts": label, hostname, user, tags |
+| Open | Hosts with a session, while any have one; a click goes to the host's latest tab |
| Hosts | The plus (**New host**) and the group icon (**New group**); "Add your first host" when empty; hosts without a group, then each group, folding (remembered in `localStorage` as `morphgate.collapsed-groups`), with **Open the group** |
-| A host row | Click connects; `user@host:port`, "via" the jump host, a VPN mark, "gone" for a host its cloud no longer lists, the shared vault's name; **Browse files (SFTP)** (hidden when its cloud offers no SFTP) and **Edit host** |
+| A host row | A status square (connected, connecting, not connected) instead of a letter; click connects; `user@host:port`, "via" the jump host, a VPN mark, "gone" for a host its cloud no longer lists, the shared vault's name; **Browse files (SFTP)** (hidden when its cloud offers no SFTP) and **Edit host** |
| Page | Content |
| --- | --- |
-| Hosts | **Import…** and **Clouds…** (desktop), **Select**, **New group**, **New host**; "Not in a group", then a section per group with **Edit group** and what its hosts use; rows with **Connect**, **Files**, **Edit**, tags, and badges "gone from AWS" or the machine's state ("stopped") |
-| Groups | Each group with its host count and defaults; **Open**, **Edit** |
-| A group | Its hosts, the tunnels through them, their files (**Save**), and "What its hosts use" (identities, keys, jump hosts, VPNs); **Move to…**, **Edit group** |
-| Keys | Keys, then **Identities** (**New identity**) and **Host CAs** |
+| Hosts | **Add from…** (desktop: **Import…**, **Clouds…**), **Quick connect** (desktop: opens quick connect, where the sign-in is chosen), **New group**, **New host**. Columns: Host (status square, name, `user@host:port`, the shared vault, "gone from AWS" or the machine's state), Route (direct, via a jump host, through a cloud or a proxy command, and the VPN, mint while up), Signs in with (identity, key, agent or password; "keeps its session", "recorded") and Tags (two, then "+N"). "Not in a group", then a section per group whose header names it (it opens the group's page), counts its hosts and shows what it gives them, with **Edit group**; a group's hosts sit indented under it, a rule down from its icon as in the sidebar. Row actions: **Connect** (**New tab** for a host with a session, whose name goes to that session), **Browse files (SFTP)**, **Edit** |
+| Groups | Group, Hosts, What its hosts get, Connected; the name opens the group's page, **Edit** on the row |
+| A group | "What its hosts use" as cards (identities, keys, jump hosts, VPNs; each opens), then its hosts as on the Hosts page, the tunnels through them, their files (**Save**); **Move to…**, **Edit group** |
+| Keys | Tabs **Keys**, **Identities**, **Host CAs** with their counts; the page's primary button follows the tab (**New key**, **New identity**, **New host CA**). Keys: fingerprint, Used by, Certificate ("None", "Valid 30 d", "Expires in 3 d" under a week, "Expired", "No expiry", "Can't be read") |
+| Snippets | **Import…**, **New snippet**; Snippet, Command (its first line, "N lines"), Tags; **Paste** and **Run** while a terminal is connected, **Edit** |
+| Tunnels | The switch first, then Tunnel, Forwards, Through (with the host's status), State ("Running · N open", "Stopped", "Starting…"); **Edit** |
+| VPNs | VPN (its kind), Server, Used by, State ("Up", "Down", "Problem", with the problem under the name); **Edit** |
## Bulk changes
diff --git a/docs/features/importing.md b/docs/features/importing.md
index 7857875..e0d10d4 100644
--- a/docs/features/importing.md
+++ b/docs/features/importing.md
@@ -4,7 +4,7 @@ People arrive with hosts in another SSH client. The desktop app reads four sourc
## The page
-The Hosts page's **Import…** (desktop only) opens "Import hosts", which reads `~/.ssh/config` straight away.
+The Hosts page's **Add from…** → **Import…** (desktop only) opens "Import hosts", which reads `~/.ssh/config` straight away.
| Control | Does |
| --- | --- |
diff --git a/docs/features/offboarding.md b/docs/features/offboarding.md
index 2d16367..ac083c6 100644
--- a/docs/features/offboarding.md
+++ b/docs/features/offboarding.md
@@ -25,6 +25,7 @@ In a team's member list, **Remove…** next to a confirmed member opens the page
| Identity | other item | "Password of {label}" when it has a saved password |
| SSH key | SSH key | "SSH key {label}", with "used by {hosts}": hosts that log in with it, directly or through an identity or group (`effective_host()`) |
| VPN profile | VPN | "VPN profile {label}" |
+| Feed ([Cloud sources](cloud-sources.md#feeds)) | other item | "Rotate the token for {label} (a feed: change it where the feed is published, then in the source's settings)": its token is in the vault |
| Member key | not counted | |
| Anything else | other item | |
@@ -59,7 +60,7 @@ The file is edited with these safety checks (`authorized_keys.rs`, `edit_keys()`
## 4. Change what they could see
-A checklist of the secrets from step 1; the ticks belong to the page and are gone when it closes. Passwords and VPN profiles are changed by hand (on the server or the VPN provider, then in the item). SSH keys have **Replace key** ("Replacing…"); a replaced key shows "replaced ({fingerprint})", and "; the old key is still on {hosts}" when removing it failed somewhere. A failure shows its message in the row.
+A checklist of the secrets from step 1; the ticks belong to the page and are gone when it closes. Passwords, VPN profiles and feed tokens are changed by hand (on the server, the VPN provider or where the feed is published, then in the item or the feed's **Settings…**). SSH keys have **Replace key** ("Replacing…"); a replaced key shows "replaced ({fingerprint})", and "; the old key is still on {hosts}" when removing it failed somewhere. A failure shows its message in the row.
### Replacing a shared SSH key
diff --git a/docs/features/port-forwarding.md b/docs/features/port-forwarding.md
index 55e4bc2..d2151c2 100644
--- a/docs/features/port-forwarding.md
+++ b/docs/features/port-forwarding.md
@@ -52,7 +52,7 @@ The form is valid with a host, a listen address, a listen port from 1 to 65535 (
| Where | Control |
| --- | --- |
-| Tunnels page (sidebar **Tunnels**, whose count reads "N on" while some run) | A switch per row ("Start tunnel" / "Stop tunnel"), **Edit**, **Select** for bulk delete and move, **New tunnel**. Rows read like `L :15432 → db:5432`, `R server:8080 → 127.0.0.1:3000` or `SOCKS :1080`, with "· N open" while connections are open, and "Through *host*" |
+| Tunnels page (sidebar **Tunnels**, whose count reads "N on" while some run) | A table: the switch first ("Start tunnel" / "Stop tunnel"), the name, what it forwards (`L :15432 → db:5432`, `R server:8080 → 127.0.0.1:3000` or `SOCKS :1080`), the host it goes through with its status, and its state ("Running", "Running · N open", "Stopped", "Starting…"); **Edit**, a "Running" filter, **Select** for bulk delete and move, **New tunnel** |
| A group's page | The tunnels through its hosts, with the same switch |
| The editor | **Save & start** |
| Phone | The **More** screen lists tunnels with switches |
diff --git a/docs/features/reference-server.md b/docs/features/reference-server.md
index 105be56..8acfbc1 100644
--- a/docs/features/reference-server.md
+++ b/docs/features/reference-server.md
@@ -73,6 +73,7 @@ Tokens are 32 random bytes, stored as SHA-256 hashes. The `AuthKey` and the reco
| `sharing` | No member or vault limits; `mails_invites: false` (creating an invite returns its token) |
| `blobs` | `max_blob_bytes`, `quota_bytes`, kinds `attachment` and `session_log` |
| `sso` | `protocols: ["oidc"]` |
+| `signed_tombstones` | `true`: a deletion's envelope (at most 1 KiB, any key generation) is kept and returned, for an item the server has |
| `limits.tombstone_retention_days` | 90 |
| Area | Endpoints (under `/v1` unless noted) |
diff --git a/docs/features/session-logs.md b/docs/features/session-logs.md
index 9bb746a..3d28858 100644
--- a/docs/features/session-logs.md
+++ b/docs/features/session-logs.md
@@ -77,7 +77,7 @@ The server can't read or delete items, so the log stays listed everywhere. Devic
## Viewing and replay
-The Session logs page (`SessionLogsPanel`) lists recordings from every vault, newest first, or one host's ("Session logs · *host*").
+The Session logs page (`SessionLogsPanel`) lists recordings from every vault, newest first, or one host's ("Session logs · *host*"), as a table: Session (its title and address; the name plays it), Started, Length, Recorded on (the device, "*size* printed") and State. Above it, a search (title, address, device) and menus that narrow it to one **Vault**, **Group** or **Host**, each shown when the recordings come from more than one.
| Way in | Where |
| --- | --- |
@@ -88,8 +88,8 @@ The Session logs page (`SessionLogsPanel`) lists recordings from every vault, ne
| In a row | Shows or does |
| --- | --- |
-| Title and state | **Synced** (`local`: every chunk here and uploaded), **Uploading** (a chunk pending), **On another device** (a chunk not here; fetched when played), **Not uploaded** (a chunk refused; the reason in the title) |
-| Details | Start time, length ("recording or cut short" without an end), "*size* printed", the address, "recorded on *device*" |
+| State | **Synced** (`local`: every chunk here and uploaded), **Uploading** (a chunk pending), **On another device** (a chunk not here; fetched when played), **Not uploaded** (a chunk refused; the reason in the title) |
+| Length | "recording or cut short" without an end |
| **Play** ("Opening…") | Assembles the cast (`session_log_cast`) and opens the player |
| **Save .cast** | `session_log_save`: Downloads (on iOS, Files › MorphGate) as `title-.cast`; characters other than letters, digits, `-` and `.` become `-`, and `-2`, `-3`… avoid overwriting |
| **Delete**, then **Really delete?** | Deletes the item; its chunks go here and, at the next sync, on the server |
diff --git a/docs/features/ssh-connections.md b/docs/features/ssh-connections.md
index ac765f6..9004d78 100644
--- a/docs/features/ssh-connections.md
+++ b/docs/features/ssh-connections.md
@@ -1,6 +1,6 @@
# SSH connections
-A connection is one SSH transport to a host, possibly through jump hosts, a VPN or a proxy command, with a PTY shell on top. Every hop's host key is checked before anything is sent to it: pinned on first use in `known_hosts`, or vouched for by a host CA. Each hop signs in with the one method its host is set to. A lost connection reconnects by itself. Code: `crates/mg-ssh/src/connection.rs` (`Connection`, `Hop`, `Auth`, `Verifier`), `crates/mg-ssh/src/session.rs` (`Session`, `ConnectOptions`), `crates/mg-ssh/src/known_hosts.rs` (`KnownHosts`, `HostAuthority`), `crates/mg-ssh/src/proxy_command.rs`, `crates/mg-ssh/src/command_stream.rs`, `crates/mg-ssh/src/error.rs`, `apps/desktop/src-tauri/src/ssh.rs` (Tauri commands), `apps/desktop/src-tauri/src/proxy.rs`, `apps/desktop/src-tauri/src/host_cas.rs`, `apps/desktop/src/ConnectForm.tsx`, `apps/desktop/src/HostKeyDialog.tsx`, `apps/desktop/src/HostCaPanel.tsx`, `apps/desktop/src/TerminalView.tsx`.
+A connection is one SSH transport to a host, possibly through jump hosts, a VPN or a proxy command, with a PTY shell on top. Every hop's host key is checked before anything is sent to it: pinned on first use in `known_hosts`, vouched for by a host CA, or listed by the host's feed. Each hop signs in with the one method its host is set to. A lost connection reconnects by itself. Code: `crates/mg-ssh/src/connection.rs` (`Connection`, `Hop`, `Auth`, `Verifier`), `crates/mg-ssh/src/session.rs` (`Session`, `ConnectOptions`), `crates/mg-ssh/src/known_hosts.rs` (`KnownHosts`, `HostAuthority`), `crates/mg-ssh/src/proxy_command.rs`, `crates/mg-ssh/src/command_stream.rs`, `crates/mg-ssh/src/error.rs`, `apps/desktop/src-tauri/src/ssh.rs` (Tauri commands), `apps/desktop/src-tauri/src/proxy.rs`, `apps/desktop/src-tauri/src/host_cas.rs`, `apps/desktop/src/ConnectForm.tsx`, `apps/desktop/src/HostKeyDialog.tsx`, `apps/desktop/src/HostCaPanel.tsx`, `apps/desktop/src/TerminalView.tsx`.
Keys, certificates and agents are in [SSH keys and agents](ssh-keys-and-agents.md); the host item and its editor in [Hosts, groups and identities](hosts-groups-identities.md); the overview in [App architecture](../architecture.md#how-a-connection-runs).
@@ -111,6 +111,10 @@ A `host_ca` item (`HostCa { label, public_key, hosts }`) trusts host certificate
Where a CA is trusted for a hop, `client_config(true)` asks the server for host certificates (Ed25519, ECDSA P-256/384/521, RSA SHA-512/256). A certificate is accepted, and nothing pinned, only if `known_hosts::certified` agrees: it is a host certificate, signed by one of the CAs trusted for this host, valid now with a good signature, its principals are empty or include the hostname, and it has no critical options. Otherwise the connection fails with the reason, as OpenSSH does; it does not fall back to pinning. A server that answers with a plain key is pinned as usual. Without a CA for the host, certificates are never asked for.
+### Keys a feed lists
+
+A host a feed keeps can come with its server's host keys (`HostSource.host_keys`, from the feed's `host_keys`; [Cloud sources](cloud-sources.md#feeds)). `saved_hop` hands them to the hop as `Hop::listed_keys` (`ListedKeys {keys, from}`, `from` the feed's name), for that host item only: another host item with the same address is checked as usual. For such a hop (`Verifier::listed`), after an `@revoked` line (which still refuses), the server's plain key must be one of the listed ones: then it is trusted, without a prompt and without being written to `known_hosts`, whatever a file pins for the address; any other key fails with "the host key of {host}:{port} ({fingerprint}) isn't one {feed} lists for it: refused. Someone may be intercepting the connection, or {feed} doesn't know the server's new key yet" (`Error::HostKeyNotListed`). Listed lines that don't parse are dropped; when none parses, the host is checked as any other. A host CA trusted for the host still decides first, where the server presents a certificate. The route bar shows "key listed by {feed}" instead of the pinned key.
+
## Jump hosts
A host's `jump_ref` (or its group's) names another host to go through; that one can have its own. `LocalVault::route` follows the chain, at most 8 jump hosts (`MAX_JUMPS`), refusing loops ("jump hosts form a loop or a chain that is too long"); saving refuses them too. A jump host must be in the same vault.
@@ -178,6 +182,7 @@ Connection errors are `mg_ssh::Error`'s text, written in red in the tab.
| "via jump host {host}:{port}: …" | Wraps any error from a later hop |
| "REMOTE HOST KEY CHANGED for {host}:{port} ({type} {fingerprint})…" | Changed key; the dialog shows first |
| "host key for {host}:{port} was not accepted" | Prompt cancelled or timed out |
+| "the host key of {host}:{port} ({fingerprint}) isn't one {feed} lists for it: refused…" | A host whose feed lists its keys answered with another |
| "the host certificate of {host}:{port} isn't accepted: {why}" | Why: "it's a user certificate, not a host's", "it's signed by {fp}, which isn't a CA trusted for this host", "it isn't valid now (expired, not yet valid), or its signature doesn't check out", "it's for {principals}, not {host}", "it has critical options, which host certificates don't" |
| "authentication failed" | The server refused the method |
| "password required" / "jump host \"{label}\" has no saved password; save one or switch it to a key" | No password to send |
@@ -215,8 +220,8 @@ Connection errors are `mg_ssh::Error`'s text, written in red in the tab.
| `crates/mg-ssh/src/proxy_command.rs` | Token expansion, refused host and user names, bytes both ways, stderr as the error |
| `crates/mg-ssh/src/connection.rs` | The macOS local-network hint |
| `apps/desktop/src-tauri/src/ssh.rs`, `proxy.rs`, `host_cas.rs` | A synced proxy command waits for allowing; tmux names; request shapes match the webview; a changed command asks again; parsing a CA key or `@cert-authority` line |
-| `crates/mg-ssh/tests/live.rs` | Against a real sshd: shell, resize, exit and TOFU; a rejected key isn't learned; a changed key fails closed; wrong password; key file; unreachable port; jump host tunnel; jump command; proxy command, and failing ones that say why; a host certificate from a trusted CA (`MG_TEST_HOST_CA`); the first hop through a dialer |
-| `apps/desktop/e2e/appimage.mjs` | Quick connect with a key file chosen in the form, and with a pasted key after a `morphgate://` link; **Trust and connect**, the pinned key in the route bar, **Host key changed** for an edited `known_hosts`, a jump host from `~/.ssh/config`, a jump command and a proxy command (via `crates/mg-ssh/tests/relay.py`), a host CA with "certified by" and no prompt |
+| `crates/mg-ssh/tests/live.rs` | Against a real sshd: shell, resize, exit and TOFU; a rejected key isn't learned; a changed key fails closed; wrong password; key file; unreachable port; jump host tunnel; jump command; proxy command, and failing ones that say why; a host certificate from a trusted CA (`MG_TEST_HOST_CA`); the first hop through a dialer; listed host keys connect without asking (over a stale pin) and aren't written down, another key is refused |
+| `apps/desktop/e2e/appimage.mjs` | Quick connect with a key file chosen in the form, and with a pasted key after a `morphgate://` link; **Trust and connect**, the pinned key in the route bar, **Host key changed** for an edited `known_hosts`, a jump host from `~/.ssh/config`, a jump command and a proxy command (via `crates/mg-ssh/tests/relay.py`), a host CA with "certified by" and no prompt; a feed's host with its listed key (no prompt), and one listed with another key refused |
The live tests are ignored unless `MG_TEST_SSH=host:port` is set (and `MG_TEST_USER`, `MG_TEST_KEY`, optionally `MG_TEST_PASSWORD`); the header of `live.rs` starts a throwaway user-level sshd. Run them with `cargo test -p mg-ssh --test live -- --include-ignored --test-threads=1`. See [Development](../development.md) for the e2e runner.
diff --git a/docs/features/ssh-keys-and-agents.md b/docs/features/ssh-keys-and-agents.md
index 6b5a4ef..cca30e2 100644
--- a/docs/features/ssh-keys-and-agents.md
+++ b/docs/features/ssh-keys-and-agents.md
@@ -6,7 +6,7 @@ How a connection uses them, and host keys, are in [SSH connections](ssh-connecti
## The Keys page
-**Keys** in the sidebar (the count is keys plus identities) opens a page: "SSH keys stay in the vault, encrypted, and are used from there: they are never written to disk." Each key shows its label, type, a **passphrase** badge when one is saved, its vault, its fingerprint and "Used by N hosts and M identities" or "Not used yet", with **Open**. **New key** is at the top. The side column holds **Identities** ([Hosts, groups and identities](hosts-groups-identities.md)) and **Host CAs** ([SSH connections](ssh-connections.md#host-cas)).
+**Keys** in the sidebar (the count is keys plus identities) opens a page: "SSH keys stay in the vault, encrypted, and are used from there: they are never written to disk." Its tabs are **Keys**, **Identities** ([Hosts, groups and identities](hosts-groups-identities.md)) and **Host CAs** ([SSH connections](ssh-connections.md#host-cas)). Each key shows its label, type, a **passphrase** badge when one is saved, its vault, its fingerprint, "N hosts, M identities" or "Not used yet", and its certificate's state; its name (or **Open** on the row) opens it. **New key** is at the top while the Keys tab is open.
### New SSH key (`NewKey`)
diff --git a/docs/features/sync.md b/docs/features/sync.md
index 222cf28..11bec7d 100644
--- a/docs/features/sync.md
+++ b/docs/features/sync.md
@@ -36,7 +36,7 @@ For morphgate.com the sign-up form runs discovery when it opens, so the terms ch
## Creating an account
-Sync starts from the Sync panel (the sidebar foot reads **Sync off**), or from the **Sync this vault?** dialog offered once right after a vault is created (`SyncOffer`, with **Not now**). Under **New account**, the device makes an account from the vault it already has. The aside lists **What the server stores**: the email and devices, encrypted items it can count but not read, and never the master password.
+Sync starts from the Sync panel (the sidebar foot reads **Sync off**), or from the **Sync this vault?** dialog offered once, a week after a vault was created without sync, the next time the workspace opens (`SyncOffer`, with **Not now**). Under **New account**, the device makes an account from the vault it already has. The aside lists **What the server stores**: the email and devices, encrypted items it can count but not read, and never the master password.
| Control | Notes |
| --- | --- |
@@ -85,7 +85,7 @@ On a server with `email_verification`, the first **Enable sync** sends `POST /v1
| Where | Command | What it does |
| --- | --- | --- |
-| First screen, **I have an account**, titled **Sign in to sync** | `sync_sign_in` | For a device with no vault ("This device already has a vault." otherwise): takes the account's keys and vault key, unlocks; items arrive with the first round |
+| First screen, **Sign in**, titled **Sign in to sync** | `sync_sign_in` | For a device with no vault ("This device already has a vault." otherwise): takes the account's keys and vault key, unlocks; items arrive with the first round |
| Sync panel, **I have an account** | `sync_join` | For a device with a vault of its own that never synced: its items, files included, move into the account's vault with the same ids, and it opens with the account's master password and recovery key from then on. Keychain unlock is set up again with the account's keys (Android's fingerprint unlock goes off). Refused with "this device syncs already" or "this device has team vaults: it synced before". A wrong password changes nothing |
| Sync panel while signed out, **Sign in again** | `sync_sign_in_again` | Same device id; re-wraps this vault's keys under the account's current password (it may have changed on another device) |
| First screen, **Forgot the master password? Use your recovery key** | `sync_recover_sign_in` | New password with the recovery key, then a sign-in; see [Account and devices](account-and-devices.md#password-changes-and-recovery) |
@@ -171,7 +171,7 @@ The personal vault failing fails the round. A shared vault failing is listed and
| `rejected`, `stale_key` | The item is re-sealed under the vault's current key and goes in the next batch |
| `rejected`, any other code | Skipped for the rest of the round, still dirty, tried again next round. Not shown anywhere (`SyncReport.rejected`) |
-A deletion goes up as a tombstone (`deleted: true`, no envelope). An item deleted before it ever synced (`rev` 0) is removed locally and never sent. A read-only vault only pulls.
+A deletion goes up as a tombstone (`deleted: true`) with its signed deletion envelope ([Item history](#item-history)); servers with `signed_tombstones` keep it (for items they have), others drop it. An item deleted before it ever synced (`rev` 0) is removed locally and never sent. A read-only vault only pulls.
## Revisions and anti-rollback
@@ -183,8 +183,21 @@ Each vault row keeps `head_rev`, the highest server revision this device has app
| A shared vault's item may not take the id of a live personal item (lookups go by id) | "item {id} of a shared vault has the id of one in your personal vault" |
| A page whose `head_rev` is below the local `head_rev` | The vault fails: "the sync server sent older data (revision {remote}) than this device already has ({local}); refusing to roll back" (`SyncError::Rollback`) |
| A pulled item no newer than the local copy | Ignored |
+| A pulled item or deletion with a version below the last one the server confirmed for it | The vault fails: "the sync server sent an older copy of item {id} (version {remote}) than this device already has (version {seen}); refusing to roll it back" (`Error::OlderItem`). Nothing from the page is applied |
| `410` on a pull (code `gone` or none; `410 invite_expired` is something else) | The cursor goes back to 0 and the vault is pulled in full; afterwards, clean synced items the server didn't send are deleted here (`drop_unseen`). The `head_rev` check doesn't apply to this full pull; the per-item check does |
+## Item history
+
+Every write carries a version inside its signed envelope (`crates/mg-store/src/history.rs`, the spec's [Item history](../sync-crypto-spec.md#sync-protocol)): the time of the write in milliseconds, or one more than the highest version this device knows of the item when that is larger (`LocalVault::next_version`). Edits, conflict copies, re-seals under a rotated key (`reseal`) and deletions (`LocalVault::tombstone`, from `delete` and from moves) all take a new one. Each item row keeps `version`, the one in its envelope, and `seen_version`, the one of the last copy the server confirmed: set by `apply_remote` and by `mark_pushed`.
+
+| Pulled | Effect |
+| --- | --- |
+| A versioned copy at or above `seen_version` | Applied; `seen_version` follows it |
+| A versioned copy below it | Refused (`Error::OlderItem`, above) |
+| A copy without a version, or a deletion without a valid signed envelope (apps before item history, 0.2.0) | Applied; `seen_version` goes to 0, so the next versioned copy is taken whatever its version (its writer may not have seen the earlier ones) |
+| The same, with strict item history on (`LocalVault::set_strict_history`, off for now) | Refused when the item has a version (`Error::UnversionedItem`) |
+| An item whose row is from before item history (`seen_version` NULL) | Compared with the version in its stored envelope |
+
## Conflicts
From the code's own statement: the server copy always wins for an item id, and a local edit that lost is kept as a new "(conflict)" item, so nothing is silently discarded. A conflict is found either when a pull brings a newer revision of an item that is dirty here, or when a push answers `conflict` with the server's current copy.
@@ -192,6 +205,7 @@ From the code's own statement: the server copy always wins for an item id, and a
| Local | Server | Result |
| --- | --- | --- |
| An edit | An edit with the same content (both re-sealed it, say) | No copy, no conflict |
+| A cloud source's change | The same source's change from another device (both refreshed it) | No copy, no conflict, when the copies differ only in what the source decides (`source_decides`): a source item only in `refreshed_at`; a host it keeps (same `source_ref` and `external_id`) only in label while it follows the source, host name, port, user, jump host, login and jump commands, the source's tags and the `source` marker; a feed's snippet, whatever it differs in. The next refresh sets it again. A person's edit (a rename, a note, a tag, a key) still makes a copy |
| An edit | A different edit | The server's version under the id; the local one as a new item (new ULID, `rev` 0, dirty), pushed like any new item |
| An edit | A deletion that got there first | The item is deleted; the edit survives as a copy |
| A deletion | An edit that got there first | No copy; the item comes back with the server's edit |
@@ -298,7 +312,7 @@ Organization endpoints are listed in [Teams](teams.md); account and device endpo
| Where | What |
| --- | --- |
-| `crates/mg-store/src/sync.rs` (`cargo test -p mg-store`) | The engine against an in-memory server (`Fake`, two items per page): `items_flow_between_devices`, `concurrent_edits_keep_both_versions`, `edit_beats_delete_as_a_copy_and_delete_loses_to_edit`, `never_synced_deletes_are_not_uploaded`, `tampered_server_data_is_rejected_whole`, `rollback_is_refused`, `up_to_date_vaults_arent_pulled`, `a_device_with_its_own_vault_joins_an_account`, `a_shared_vault_cant_shadow_a_personal_item`, `kinds_from_newer_apps_are_kept_but_not_listed`, `token_and_config_roundtrip_and_disable`, `vaults_without_a_stored_recovery_proof_rotate_on_registration`, `adopt_requires_the_right_password_and_a_fresh_device`, and the blob tests (`session_log_blobs_upload_fetch_and_delete`, `refused_blobs_are_reported_and_retried`, `attachments_sync_and_go_with_their_host`) |
+| `crates/mg-store/src/sync.rs` (`cargo test -p mg-store`) | The engine against an in-memory server (`Fake`, two items per page): `items_flow_between_devices`, `concurrent_edits_keep_both_versions`, `edit_beats_delete_as_a_copy_and_delete_loses_to_edit`, `never_synced_deletes_are_not_uploaded`, `tampered_server_data_is_rejected_whole`, `rollback_is_refused`, `an_older_copy_of_an_item_is_refused`, `a_deleted_item_cant_be_brought_back`, `changes_from_apps_before_item_history_still_sync`, `strict_history_refuses_unversioned_changes`, `copies_from_before_item_history_keep_their_version`, `resealing_gives_a_new_version`, `deletions_sync_through_servers_that_drop_their_envelope`, `up_to_date_vaults_arent_pulled`, `a_device_with_its_own_vault_joins_an_account`, `a_shared_vault_cant_shadow_a_personal_item`, `kinds_from_newer_apps_are_kept_but_not_listed`, `token_and_config_roundtrip_and_disable`, `vaults_without_a_stored_recovery_proof_rotate_on_registration`, `adopt_requires_the_right_password_and_a_fresh_device`, and the blob tests (`session_log_blobs_upload_fetch_and_delete`, `refused_blobs_are_reported_and_retried`, `attachments_sync_and_go_with_their_host`) |
| `crates/mg-sync/src/client.rs` | `payment_errors_say_what_to_do` |
| `server/tests/e2e.rs`, `protocol.rs`, `account.rs` | Real vaults through the reference server over HTTP: a second device signs in and syncs, push, pull, conflicts and tombstones, refresh rotation and reuse detection, email verification and two-step verification where offered. `VAULT_SYNC_URL=https://… cargo test -p vault-sync-server --test protocol --test e2e --test account` runs them against another server |
| `tools/vault-conform` | The `auth`, `account` and `vaults` groups check a server's side of all of this. See [Development](../development.md#conformance-suite) |
diff --git a/docs/features/teams.md b/docs/features/teams.md
index 6c31a2b..770f392 100644
--- a/docs/features/teams.md
+++ b/docs/features/teams.md
@@ -65,34 +65,41 @@ Certificates bind keys to accounts and grant nothing; access stays with the serv
Every verified key is pinned in `pinned_keys` with the email. A known account whose keys change is a hard error, never a prompt: "the keys of {email} changed; they must be removed and invited again". Members who don't verify go to `Trust.unverified`; the member list says "Keys not verified yet" and nothing is sealed to them ("can't verify that member's keys").
+## The pages
+
+**Teams** (`TeamsPanel`) lists the teams this account is in as a table: Team, You are (its role, or "Joined" until an admin confirms it), Vaults you open, and Needs you, from what this device knows: "Turn on two-step verification", "Sign in with single sign-on", "This device waits for approval" (or "This device was denied"), "{n} devices to approve", "Publish your SSH keys", "Rotate a vault's key"; "An admin confirms you next" while waiting. **New team** is the page's primary action. Under the list: **Join with an invite** and **Your fingerprint**.
+
+A team's page (`OrgView`) has the plan and single sign-on lines under its name; its actions are **New vault** (for those who may create vaults), **Choose a plan ↗** / **Manage plan ↗** (the owner, on desktop, on a server with plans) and **Invite people** for owners and admins (its primary action); then **Needs you** above its tabs: members to confirm (**Confirm {name}…**) or certify again (**Verify…**), devices waiting (**Approve**, **Deny**), vaults whose key should be rotated (**Rotate key**), two-step verification the team requires, and the SSH keys it asks members to publish (**Publish a key**). Tabs: **Members** (members, then the invites not accepted yet: Role, Keys ("Verified", "To confirm", "Not verified"), SSH keys; then **Devices waiting**), **Vaults** (Items, You can, Key; **Access**, **Rotate key**), **Your SSH keys**, and for owners and admins **Settings** and **Activity**. Members see **Vaults** first. Before an admin confirms you, the page only says so, with your fingerprint and **Leave**. Lists are the tables of the other pages (`ListParts.tsx`).
+
## Creating a team
-**New organization** (placeholder "Acme Ops"), **Create**. `create_org()` picks a ULID, creates the organization, checks that the server put this account's real keys at the root of the owner chain, pins the root and creates the member key directory (if that fails, the next sync does it). A server with plans (such as morphgate.com) may refuse with `402 team_limit`, whose `limit` is how many teams an account may own without a plan. The app then says "you own {n} teams without a plan: choose a plan for one of them, or delete one, before creating another" and, on desktop, offers **Choose a plan ↗** (the account portal at `/account`); iOS and Android builds say "…, the most you can: delete one before creating another".
+**New team** opens a bar: **Team name** (placeholder "Acme Ops"), **Create team**. `create_org()` picks a ULID, creates the organization, checks that the server put this account's real keys at the root of the owner chain, pins the root and creates the member key directory (if that fails, the next sync does it). A server with plans (such as morphgate.com) may refuse with `402 team_limit`, whose `limit` is how many teams an account may own without a plan. The app then says "you own {n} teams without a plan: choose a plan for one of them, or delete one, before creating another" and, on desktop, offers **Choose a plan ↗** (the account portal at `/account`); iOS and Android builds say "…, the most you can: delete one before creating another".
## Inviting
-Owners and admins see **Invites** beside the team.
+**Invite people** (owners and admins; disabled while the team is locked) opens a dialog.
| Control | Does |
| --- | --- |
-| Email field (placeholder "colleague@example.com"), **Invite** | Enabled for a well-formed address (`^[^@\s]+@[^@\s]+$`) at an allowed domain ("Invites go only to @…") |
-| **Member** / **Admin** select | Owners only; only the owner invites admins. Admin invites carry no vault grants |
-| "Vaults they get once you confirm them:" checkboxes | Members only; each ticked vault gets an access select, **Can edit** by default |
-| Pending list, **Cancel** | One row per invite with "Expires {date}" |
-| "This server doesn't email invites. Send this code to {email} (it works once, for that address, for 7 days):", **Copy code**, **Done** | When the server returned the token (`mails_invites: false`) |
-| "Invite emailed to {email}." | Otherwise |
+| **Email addresses** (placeholder "colleague@example.com, another@example.com"), **Send invite** / **Send {n} invites** | Several at once, separated by commas, semicolons or spaces; each must be well formed (`^[^@\s]+@[^@\s]+$`, else "Not an email address: …") and at an allowed domain ("Invites go only to @…: not …"). One `org_invite` per address; those refused are named, the rest are sent |
+| **Role** | Owners only ("Member: the vaults you give them", "Admin: every vault and member"); only the owner invites admins. Admin invites carry no vault grants |
+| "Vaults they get once you confirm them" checkboxes | Members only; each ticked vault gets an access select, **Can edit** by default |
+| "This server doesn't email invites. Send each person their code (it works once, for that address, for 7 days):", a code per address with **Copy code**, **Done** | When the server returned the tokens (`mails_invites: false`) |
+| "Invite emailed to {emails}." | Otherwise |
+
+Invites not accepted yet are rows of **Members**: "Invited · works until {date}", **Cancel invite**.
-An invite page on the web opens `morphgate://invite?token=…&server=…` (or `morphgate://invite/{token}`). The app opens Teams with the code looked up, after checking that sync is on (otherwise it opens Sync: "Turn on sync with the invited address first, then open the link again") and that `server` is the server this device syncs with. A link never joins by itself. See [Deep links](deep-links.md).
+An invite page on the web opens `morphgate://invite?token=…&server=…` (or `morphgate://invite/{token}`). The app opens Teams with the code looked up, after checking that sync is on (otherwise it opens Sync, and Teams once sync is on) and that `server` is the server this device syncs with. Opened before the app has a vault, the first screen shows the invite and offers a new account with its address ([First run](vault-and-unlock.md#first-run)). A link never joins by itself. See [Deep links](deep-links.md).
## Joining
**Join with an invite**: **Invite code**, **Look up**. `preview_invite()` reads the invite and verifies its owner chain from the first link, then shows the team, the invited address, the role, the sender and the current owner's fingerprint. The person ticks **It matches the fingerprint they read to me** and chooses **Join {team}**.
-`accept_invite()` reads the invite again and refuses when the owner's fingerprint is no longer the one shown ("the organization's keys changed while you were looking; check the fingerprint again") or when this device already pinned another root ("this organization's root key changed"). Then it accepts and pins the root. The member is `accepted`: the list says "Waiting for an admin to confirm you", and the team page shows their own fingerprint to read out, with **Leave**.
+`accept_invite()` reads the invite again and refuses when the owner's fingerprint is no longer the one shown ("the organization's keys changed while you were looking; check the fingerprint again") or when this device already pinned another root ("this organization's root key changed"). Then it accepts and pins the root. The member is `accepted`: the Teams list says "An admin confirms you next", and the team page shows their own fingerprint to read out, with **Leave**.
## Confirming and verifying members
-An admin chooses **Confirm…** next to a member "Waiting for confirmation", asks them to read their fingerprint (Teams, on their device), ticks **It matches what they read to me** and chooses **Confirm {email}**. `confirm_member()`:
+An admin chooses **Confirm…** on a member marked "To confirm" (or **Confirm {name}…** in Needs you), asks them to read their fingerprint (Teams, on their device), ticks **It matches what they read to me** and chooses **Confirm {email}**. `confirm_member()`:
1. syncs the organization first (`refresh()`), then refuses a member already confirmed;
2. compares the fingerprint of the keys the server lists now with the one the admin compared ("their keys don't match the fingerprint you compared; compare again") and with any pin ("this account's keys changed");
@@ -108,17 +115,17 @@ A member certificate counts only while its signer is an owner or admin. When the
| Role | In the app |
| --- | --- |
-| Owner | Everything below, plus **Make admin** / **Make member**, invites and confirms admins, removes admins, **Ownership** (transfer, delete), **Choose a plan ↗** / **Manage plan ↗** on desktop |
+| Owner | Everything below, plus the role select on members' rows, invites and confirms admins, removes admins, **Ownership** (transfer, delete), **Choose a plan ↗** / **Manage plan ↗** on desktop |
| Admin | Opens and manages every vault, invites members, confirms, removes members, policies, **Activity**, device decisions |
| Member | Opens the vaults granted, publishes SSH keys, approves their own new devices, creates vaults if the policy allows |
`set_role()` to admin verifies the member's keys, seals them every organization vault's key ring and adds an admin certificate. To member it sends no keys; the server drops access that came only with the role and flags those vaults for rotation.
-**Transfer ownership** (enabled once there is a confirmed admin; "To hand the organization over, make someone an admin first.") signs an owner certificate for the chosen admin; the old owner stays an admin. **Delete organization** deletes it with all its vaults, then removes them from this device. Both ask for **Your master password**, sent as the AuthKey.
+**Transfer…** under **Ownership** on Settings (enabled once there is a confirmed admin; "To hand the team over, make someone an admin first.") signs an owner certificate for the chosen admin; the old owner stays an admin. **Delete team…** deletes it with all its vaults, then removes them from this device. Both ask for **Your master password**, sent as the AuthKey.
## Shared vaults and access
-**New vault** (placeholder "Staging") is shown to owners and admins, and to members when `members_can_create_vaults` is on. `create_vault()` seals a new vault key to every confirmed owner and admin (each must verify: "can't verify the keys of {email}") and to the creator, and puts the name in an encrypted `meta` item signed by the creator, so the server never sees it.
+**New vault**, a page action shown to owners and admins and to members when `members_can_create_vaults` is on, opens the Vaults tab with a bar: **Vault name** (placeholder "Staging"), **Cancel**, **Create vault**. `create_vault()` seals a new vault key to every confirmed owner and admin (each must verify: "can't verify the keys of {email}") and to the creator, and puts the name in an encrypted `meta` item signed by the creator, so the server never sees it.
| Access | Label | Can |
| --- | --- | --- |
@@ -126,11 +133,11 @@ A member certificate counts only while its signer is an owner or admin. When the
| `write` | **Can edit** | Also add, change and delete items |
| `manage` | **Can manage** | Also see who has access, grant, revoke, rotate |
-**Access** next to a vault (managers, not the directory) lists who has it. Owners and admins show "Can manage (owner or admin)" and can't be changed. **Give a member access…** lists confirmed plain members without access, with an access select (**Can edit** by default) and **Add**. `set_access()` only changes the permission for someone who already has the vault; a new grant verifies their keys and seals every key generation. **Remove** revokes, then explains: "They can't sync this vault anymore, but what they already had stays on their devices. Rotate the key (the button appears after the next sync) so they can't read anything added from now on, and change the passwords and keys they could see."
+**Access** on a vault's row (Vaults tab; managers, not the directory) opens under it who has it. Owners and admins show "Can manage (owner or admin)" and can't be changed. **Give a member access…** lists confirmed plain members without access, with an access select (**Can edit** by default) and **Add**. `set_access()` only changes the permission for someone who already has the vault; a new grant verifies their keys and seals every key generation. **Remove** revokes, then explains: "They can't sync this vault anymore, but what they already had stays on their devices. Rotate the key (the button appears after the next sync) so they can't read anything added from now on, and change the passwords and keys they could see."
## Key rotation
-The server sets `rotation_due` on a vault when anyone loses access to it: revoked, removed, left, or made a member again. The vault row then says "someone lost access since the key was made"; managers get **Rotate key** ("Rotating…") and the sidebar shows **Teams · rotate a key**.
+The server sets `rotation_due` on a vault when anyone loses access to it: revoked, removed, left, or made a member again. The vault's Key column then says "Rotate" (to managers; "Rotation due" to others), Needs you says "{vault}'s key should be rotated: someone lost access since it was made.", managers get **Rotate key** ("Rotating…") there and on the row, and the sidebar shows **Teams · rotate a key**.
`rotate()` syncs the organization first, generates a vault key, seals it with a grant signature to every account the server lists as having access (each must verify), and switches with `previous_key_id` set to the current key (the server refuses a stale one with `409`). This device keeps every older generation in `vault_keys` and re-seals its own copies of the vault's items under the new key; the next sync pushes them. Any device with write access finishes what is left on its next sync, and a push under an old key (`stale_key`) is re-sealed and sent again. Rotation keeps the person who left out of what is written next; it can't take back what they synced. That is [Offboarding](offboarding.md).
@@ -154,35 +161,35 @@ When a round finds the account in fewer organizations than the device knows (lef
## Policies
-Owners and admins edit **Policies**; **Save policies** appears once something changed. `org_policies_set` always sends the whole set.
+Owners and admins change them on **Settings**, grouped (Security, Members, Session logs), each with what it does and a switch or a field; **Save settings** and **Discard** appear once something changed. `org_policies_set` always sends the whole set.
| Policy | Control | Notes |
| --- | --- | --- |
-| `require_totp` | **Require two-step verification to open the organization's vaults** | Disabled without `features.totp` unless on ("(this server doesn't offer two-step verification)") |
-| `members_can_create_vaults` | **Members can create vaults (they manage the ones they create)** | |
-| `allowed_email_domains` | **Invite only addresses at these domains (empty: any)** | Split on commas and spaces, leading `@` dropped, lowercased; the invite form checks it too |
-| `require_device_approval` | **Owners and admins approve new devices before they open the organization's vaults (…)** | Disabled unless the plan lists `device_approval` (or the server has no plans) or it is on: "Needs the Business plan." |
-| `require_member_keys` | **Members publish the SSH keys they use on the organization's servers (so leaving can remove them); the app reminds those who haven't** | `Option`, sent only once set, since a server may refuse a policy it doesn't know. Only the apps act on it |
-| `session_log_retention_days` | **Delete the team's session logs after this many days (0 keeps them)** | Only with `features.blobs`; 0 to 3650 (`min(3650)`) |
-| `require_sso` | None; a hint when on | Set on the server along with the identity provider (on the reference server, `vault-sync-server sso --require`); sent back unchanged |
+| `require_totp` | **Require two-step verification** | Disabled without `features.totp` unless on ("This server doesn't offer two-step verification."). Members without it are told where they look for the vaults ([Two-step verification required](account-and-devices.md#two-step-verification-required)) |
+| `members_can_create_vaults` | **Members can create vaults** | |
+| `allowed_email_domains` | **Invite only these domains** (empty: any) | Split on commas and spaces, leading `@` dropped, lowercased; the invite form checks it too |
+| `require_device_approval` | **Approve new devices** | Disabled unless the plan lists `device_approval` (or the server has no plans) or it is on, marked **Business plan** |
+| `require_member_keys` | **Ask members to publish their SSH keys** (the app reminds those who haven't) | `Option`, sent only once set, since a server may refuse a policy it doesn't know. Only the apps act on it |
+| `session_log_retention_days` | **Delete session logs after** {n} days (0 keeps them) | Only with `features.blobs`; 0 to 3650 (`min(3650)`) |
+| `require_sso` | None; a **Single sign-on** row saying "Required" when on | Set on the server along with the identity provider (on the reference server, `vault-sync-server sso --require`); sent back unchanged |
The server enforces every policy but `require_member_keys`. It may check more than the app does: the reference server refuses an entry in `allowed_email_domains` that isn't a domain name ("not a domain: …"), and refuses `require_totp` with `501 not_supported`, since it has no two-step verification.
## Plans
-On a server with plans (such as morphgate.com), `org_details` carries the team's plan. The team page shows what the server reports: "{Plan} trial until {date}", "{Plan} plan, renews {date}", "The {Plan} plan ended {date}. Shared vaults keep syncing until {date}." (status `grace`), or "No plan", with seats used. Where the server enforces plans (`billing.plans_enforced`) and the team has no usable plan (`trialing`, `active` or `grace`), the team is locked: invites, policies, new vaults, publishing keys, device decisions, confirming and role changes are disabled, and its vaults count as view-only everywhere in the app (`orgs_locked`, `vaultEntries()`). Removing people, leaving, transferring and deleting stay possible. Phones and iPads (`mobileOs`) show no plan buttons: the app stores don't allow pointing at purchases outside them.
+On a server with plans (such as morphgate.com), `org_details` carries the team's plan. The team page shows what the server reports: "{Plan} trial until {date}", "{Plan} plan, renews {date}", "The {Plan} plan ended {date}. Shared vaults keep syncing until {date}." (status `grace`), or "No plan", with seats used; the owner's **Choose a plan ↗** (no plan) or **Manage plan ↗** beside **Invite people** opens the team's billing page in the account portal (`/account/team/{org}/billing`). Where the server enforces plans (`billing.plans_enforced`) and the team has no usable plan (`trialing`, `active` or `grace`), the team is locked: invites, policies, new vaults, publishing keys, device decisions, confirming and role changes are disabled, and its vaults count as view-only everywhere in the app (`orgs_locked`, `vaultEntries()`). Removing people, leaving, transferring and deleting stay possible. Phones and iPads (`mobileOs`) show no plan buttons: the app stores don't allow pointing at purchases outside them.
## Device approval, single sign-on and activity
-- **Devices** / **Waiting for approval** lists pending and denied devices (everyone's for owners and admins, your own otherwise) with **Approve** and **Deny**; this device's own row says "Approve it from another of your devices". A device that waits shows **Waiting for approval · {team}** (or "{team} denied this device") in the sidebar, which opens the `ApprovalWait` dialog (**Check again** syncs now).
+- **Devices waiting** (Members tab) lists pending and denied devices (everyone's for owners and admins, your own otherwise) with **Approve** and **Deny**; pending ones are in Needs you too; this device's own row says "Approve it from another of your devices". A device that waits shows **Waiting for approval · {team}** (or "{team} denied this device") in the sidebar, which opens the `ApprovalWait` dialog (**Check again** syncs now).
- With a provider configured, the team page says "Single sign-on at {host}" and either "This device is signed in until {time}" or **Sign in**. Held vaults show **Sign in to {team}** in the sidebar. `SsoSignIn` opens the provider in the browser (`org_sso_start`), polls `org_sso_status` every 2 seconds (`POLL_MS`) until a session appears, then syncs; past `expires_in` it says "The sign-in expired. Start it again." The provider's protocol (OpenID Connect or SAML) is the server's business; the app only opens the address the server gives.
-- **Activity** (owners and admins), **Show activity**: `org_activity` reads every page (1000 events each, at most `ACTIVITY_PAGES` = 1000 pages) and keeps the newest `ACTIVITY_KEPT` = 5000; the panel shows 50 at a time (**Show older**). Names come from pinned emails and local vault names ("a former member", "a vault you can't open"). **Download CSV** and **Download JSON lines** save `morphgate-activity-{team}-{date}.{csv|jsonl}` in Downloads.
+- The **Activity** tab (owners and admins): `org_activity` reads every page (1000 events each, at most `ACTIVITY_PAGES` = 1000 pages) and keeps the newest `ACTIVITY_KEPT` = 5000; the panel shows 50 at a time (**Show older**). Names come from pinned emails and local vault names ("a former member", "a vault you can't open"). **Download CSV** and **Download JSON lines** save `morphgate-activity-{team}-{date}.{csv|jsonl}` in Downloads.
## Member key directory
-Offboarding needs to know which `authorized_keys` lines belong to someone, so members publish the public halves of the keys they use on the team's servers. The directory is an ordinary organization vault named "Member keys" (`DIRECTORY_NAME`) whose encrypted meta says `directory: true`, so servers need nothing new. Owners' and admins' apps create it and keep every confirmed member at `write`; if two appear, the lowest id is used. It shows as **Every member** with "Members' SSH public keys: {n} published".
+Offboarding needs to know which `authorized_keys` lines belong to someone, so members publish the public halves of the keys they use on the team's servers. The directory is an ordinary organization vault named "Member keys" (`DIRECTORY_NAME`) whose encrypted meta says `directory: true`, so servers need nothing new. Owners' and admins' apps create it and keep every confirmed member at `write`; if two appear, the lowest id is used. On the Vaults tab it shows as "Member keys" ("Members' SSH public keys"), **Every member**.
-**Your SSH keys** (until the directory reaches this device: "The organization's member key directory arrives with the next sync."): **Publish one of your keys…** lists the SSH keys of the personal vault not yet published; **Publish** (`member_key_publish`) stores a `member_key` item (`account_id`, `public_key` as OpenSSH `type base64`, `label`, `sig`) signed by this account; publishing the same key again returns the existing entry. **Unpublish** deletes the item. A reader accepts an entry only if its signature verifies with that member's own pinned key, so nobody else, the server included, can publish a key in someone's name; an entry still verifies after its author leaves. The member list shows "{n} SSH keys published" (verified entries only). With `require_member_keys` on, members without one see "none yet, and the organization asks for them" and the sidebar shows **Teams · publish your keys**.
+**Your SSH keys** (until the directory reaches this device: "The organization's member key directory arrives with the next sync."): **Publish one of your keys…** lists the SSH keys of the personal vault not yet published; **Publish** (`member_key_publish`) stores a `member_key` item (`account_id`, `public_key` as OpenSSH `type base64`, `label`, `sig`) signed by this account; publishing the same key again returns the existing entry. **Unpublish** deletes the item. A reader accepts an entry only if its signature verifies with that member's own pinned key, so nobody else, the server included, can publish a key in someone's name; an entry still verifies after its author leaves. The member list's SSH keys column shows "{n} published" (verified entries only). With `require_member_keys` on, members without one show "None yet", Needs you asks them to publish one (**Publish a key**), and the sidebar shows **Teams · publish your keys**.
## Data on this device
diff --git a/docs/features/terminal-and-snippets.md b/docs/features/terminal-and-snippets.md
index 064aeec..6a5ae95 100644
--- a/docs/features/terminal-and-snippets.md
+++ b/docs/features/terminal-and-snippets.md
@@ -76,7 +76,7 @@ A login command wins; the host editor disables the tmux checkbox while one is se
| `+N` | A split with N more panes; the title says when typing goes to all of them |
| Close (×) | "Close tab", or "Close the tab and its panes" for a split |
-Sessions open from the sidebar (click a host), the Hosts page (**Connect**), and **Quick connect** (the plus at the end of the tab bar). A host can have any number of tabs. Every `TerminalView` stays mounted whatever is shown, so hidden sessions keep running. Locking the vault unmounts the workspace, and each `TerminalView` disconnects as it goes; `vault_lock` itself stops tunnels, SFTP and VPNs. Tabs are kept in memory only: they are not restored after a restart or an unlock.
+Sessions open from the sidebar (click a host), the Hosts page (**Connect**), and **Quick connect** (the plus at the end of the tab bar, or the Hosts page's **Quick connect**). A host can have any number of tabs. Every `TerminalView` stays mounted whatever is shown, so hidden sessions keep running. Locking the vault unmounts the workspace, and each `TerminalView` disconnects as it goes; `vault_lock` itself stops tunnels, SFTP and VPNs. Tabs are kept in memory only: they are not restored after a restart or an unlock.
## Split panes
@@ -112,7 +112,21 @@ Shown above terminal tabs (`RouteBar.tsx`). Its menus close on a click elsewhere
## Snippets
-A `snippet` item: `name` (trimmed), `body` (exactly as typed), `tags` (trimmed, empty ones dropped, left out of the JSON when there are none). Lists sort by name, ignoring case. A snippet lives in any vault; in a view-only vault it can be sent but not edited. Importing from CSV is in [Importing](importing.md).
+A `snippet` item: `name` (trimmed), `body` (exactly as typed), `tags` (trimmed, empty ones dropped, left out of the JSON when there are none), and `source` (`SnippetSource {source_ref, external_id}`) when a feed keeps it. Lists sort by name, ignoring case. A snippet lives in any vault; in a view-only vault it can be sent but not edited. Importing from CSV is in [Importing](importing.md).
+
+### A feed's snippets
+
+A feed ([Cloud sources](cloud-sources.md#feeds)) brings snippets as well as hosts. The feed decides them whole: each refresh sets name, body and tags, and deletes one the feed no longer lists (nobody's edits are lost: they can't be edited). In the app they are read-only:
+
+| Where | Difference |
+| --- | --- |
+| Snippets page | "From {feed} (read-only)" under the snippet's name; the row's icon is **Open** rather than **Edit** |
+| Editor | "From the feed **{feed}**: it changes there, and is read-only here." with **Make a copy** (a snippet of one's own, "{name} (copy)", in the same vault, or the personal one when that's view-only, which then opens); the fields are read-only, and there's no **Save** or **Delete** |
+| `snippet_save`, `snippet_delete` | Refused: "\"{name}\" comes from {feed}: it changes there, or make a copy to change it here" |
+| Bulk delete | Left out: "\"{name}\" comes from a feed: it goes when the feed drops it" |
+| Removing the feed | **Remove it, its hosts and snippets** deletes them; **Remove it, keep the hosts and snippets** makes them ordinary snippets |
+
+**Run** and **Paste** ask first when the feed changed the body since this device last sent it: "{name} changed", "Changed by **{feed}** on {date}, since you last ran it on this device. It now runs:" with the body, **Cancel** and **Run it** (or **Paste it**) (`SnippetCheck.tsx`). Every place a snippet is sent goes through `guardSnippet` in `App.tsx`: the route bar's menu, the Snippets page, the editor and the phone's **Snippets** sheet. After a feed's snippet is sent, `snippet_sent` keeps the SHA-256 of its body on this device (setting `snippet-sent:`, not synced); `snippets_list` sets `changed` when that hash is there and differs from the body's. A snippet never sent from this device doesn't ask.
| Control (`SnippetPanel.tsx`) | Does |
| --- | --- |
@@ -122,7 +136,7 @@ A `snippet` item: `name` (trimmed), `body` (exactly as typed), `tags` (trimmed,
| **Delete**, then **Really delete?** | Deletes |
| **Close**, **Save snippet** / **Save changes** | |
-The Snippets page (sidebar **Snippets**) has **Edit** per row, and **Paste** and **Run** while a terminal is connected, **Select** for bulk delete and move, **Import…** and **New snippet**. On a phone, the terminal's **More** menu has **Snippets**.
+The Snippets page (sidebar **Snippets**) lists them as a table (name, the command's first line, tags), with **Edit** per row (or the name), and **Paste** and **Run** while a terminal is connected, a search and tag filters, **Select** for bulk delete and move, **Import…** and **New snippet**. On a phone, the terminal's **More** menu has **Snippets**.
| Behavior | Detail |
| --- | --- |
@@ -136,6 +150,7 @@ The Snippets page (sidebar **Snippets**) has **Edit** per row, and **Paste** and
| `snippets_list` | All snippets in every vault |
| `snippet_save` | `{id?, vaultId?, name, body, tags}`; a new snippet goes to `vaultId` or the personal vault |
| `snippet_delete` | `id` |
+| `snippet_sent` | `id`: this device sent a feed's snippet (keeps its body's hash) |
## Keyboard
@@ -162,6 +177,7 @@ The phone layout (`mobile/MobileWorkspace.tsx`) is used on Android and iPhone, a
| `apps/desktop/e2e/teams.mjs` | A group's identity and tmux session; **Reconnect** picks the tmux session back up |
| `apps/desktop/e2e/mobile.mjs` | Ctrl and ↑ from the extra-key row; Esc then a snippet runs as written; a snippet imported from CSV |
| `apps/desktop/src-tauri/src/bulk.rs`: `deletes_snippets_and_groups_and_says_what_it_leaves` | Bulk delete |
+| `apps/desktop/e2e/appimage.mjs` (the feed) | A feed's snippet runs; it's read-only with **Make a copy**; changed by the feed, running it asks first, once |
The e2e scenarios run with `apps/desktop/e2e/run.sh` (`E2E_ONLY=appimage`, `teams` or `mobile` for one).
diff --git a/docs/features/updates.md b/docs/features/updates.md
index b8823aa..2d16eaf 100644
--- a/docs/features/updates.md
+++ b/docs/features/updates.md
@@ -50,11 +50,12 @@ After installing, `app.restart()` quits. On the way out the shell stops tunnels,
| Control | Does |
| --- | --- |
| Version line | "Version 0.2.0 · AppImage" (`PACKAGE_NAMES`: AppImage, Debian package, RPM package, macOS app, Windows installer (MSI), Windows installer), or "· development build". Phones show the version alone |
+| **Documentation ↗**, **Source code on GitHub ↗** | `https://morphgate.com/docs` and `https://github.com/Vallic/MorphGate`, in the browser (`open_link`) |
| **Open-source licenses** | The third-party notices ([below](#open-source-licenses)) |
| **Check for updates automatically** | `set_auto_update_check`. Hint: "At launch and twice a day MorphGate asks GitHub for the latest release. Nothing from your vault is sent. Updates are signed, and MorphGate installs only ones signed with its key." |
| Update notice | "MorphGate X is available", "Released …", and the release notes |
| **Install and restart** | `update_install`. Before it: "Open terminals, file browsers, tunnels and VPNs close when MorphGate restarts." (plus "Installing a package asks for an administrator password." for deb and rpm). During: a progress bar, "Downloading… 12.3 MiB of 40.1 MiB", then "Installing… MorphGate restarts when it's done." |
-| **Check now** | `update_check`; "Checking…" while it runs; "MorphGate is up to date." when nothing is newer |
+| **Check now** | `update_check`; "Checking…" while it runs, and **Install and restart** waits for it (Install takes the update the last finished check found); "MorphGate is up to date." when nothing is newer |
| **Close** | Back to the workspace |
On phones the Updates section is one line instead: "MorphGate updates through the App Store." on iOS, "MorphGate updates through Google Play, or by installing the next APK." on Android.
diff --git a/docs/features/vault-and-unlock.md b/docs/features/vault-and-unlock.md
index 342b80b..2f4f9dd 100644
--- a/docs/features/vault-and-unlock.md
+++ b/docs/features/vault-and-unlock.md
@@ -49,7 +49,7 @@ Each item's plaintext is an `ItemData`, JSON with a `type` tag (`model.rs`, `SCH
| `vpn` | VPN profiles | [VPN](vpn.md) |
| `session_log` | A recorded session, its chunks in blobs | [Session logs](session-logs.md) |
| `terminal_theme` | Imported color schemes | [Appearance](appearance.md) |
-| `cloud_source` | A cloud account whose machines become hosts | [Cloud sources](cloud-sources.md) |
+| `cloud_source` | A cloud account or feed whose machines become hosts | [Cloud sources](cloud-sources.md) |
| `member_key` | A member's SSH public key, in an organization's directory vault | [Teams](teams.md) |
Store-level rules that apply to every type:
@@ -62,18 +62,23 @@ Store-level rules that apply to every type:
| Saving an existing item into another vault | `to put an item in another vault, copy or move it` |
| Deleting an item others refer to | ` is still used by: ` |
-Deleting leaves a tombstone (`envelope` NULL, `deleted` 1) so the deletion syncs. Deleting a host deletes its attachments; deleting a group makes what its hosts took from it their own.
+Deleting leaves a tombstone (`deleted` 1) holding the signed deletion (`{"type":"deleted","item_version":n}` in an envelope of the item) so the deletion syncs, and other devices can tell it from one a server made up ([Item history](sync.md#item-history)). Deleting a host deletes its attachments; deleting a group makes what its hosts took from it their own.
## First run
-`App.tsx` shows `SetupScreen` while the status is `uninitialized`. A segmented choice offers **New vault** and **I have an account** (signing in to an existing sync account; see [Account and devices](account-and-devices.md)).
+`App.tsx` shows `SetupScreen` while the status is `uninitialized`. A segmented choice offers **New vault** (on this device only; if sync is still off a week later, it is offered once), **New account** (the vault and the sync account that keeps it on other devices, in one go) and **Sign in** (an existing sync account; see [Sync](sync.md#signing-in)). The same screen, on every platform.
| Step | UI |
| --- | --- |
-| **Create your vault** | **Master password**, the strength meter, **Repeat password** ("Passwords don't match."), **Create vault** (enabled once the password is strong and repeated; "Deriving keys…" while busy) |
+| **Create your vault** | **Master password**, the strength meter, **Suggest a strong password**, **Repeat password** ("Passwords don't match."), **Create vault** (enabled once the password is strong and repeated; "Deriving keys…" while busy) |
+| **Create your account** | **Sync server**, **Email**, the password fields as above, the server's terms where it has them, **Continue** (the server mails a code: **Code sent to {email}**) or **Create account**. It creates the vault (`vault_create`), then the account with the same password (`sync_enable`). If the account then fails (a wrong code, say), the vault stays and the form keeps the rest: another try needs no new vault, and **Use the vault without an account for now** goes on to the recovery key (and sync is offered a week later, as after **New vault**) |
| **Save your recovery key** | The code in groups, **Copy to clipboard** ("Copied", and "MorphGate clears the clipboard when you continue, so the key doesn't stay there."), the checkbox **I stored it somewhere safe, away from this device**, then **Open my vault** (enabled once ticked), which clears the clipboard if the key was copied; a minute on the screen clears it too, where the webview allows that without a click (`RecoveryCode` in `VaultGate.tsx`) |
-`vault_create` checks the password (`password::check`), then `LocalVault::create` runs in one transaction: new KDF parameters, the master key, random account keys, a random recovery key, a personal vault (ULID) and its key sealed to the account. It returns the recovery key; the command returns its code, which the UI shows once and never stores. A freshly created vault then gets a one-time offer to turn on sync. After a device was erased, the first screen says why above the form (see [Resetting](#resetting)).
+`vault_create` checks the password (`password::check`), then `LocalVault::create` runs in one transaction: new KDF parameters, the master key, random account keys, a random recovery key, a personal vault (ULID) and its key sealed to the account. It returns the recovery key; the command returns its code, which the UI shows once and never stores. A vault created without an account gets a one-time offer to turn on sync a week later, not at once: the first screen offered an account already. The time is kept in the WebView's local storage (`syncOfferTime` in `SyncPanel.tsx`), checked when the workspace opens, and dropped once the offer is answered or sync is on. After a device was erased, the first screen says why above the form (see [Resetting](#resetting)).
+
+**Invited before there's a vault.** An invite link opened before the vault exists (the website's **Open in MorphGate**) waits in the link queue; the first screen reads it without taking it (`links_peek`) and looks it up on the server the link names, which needs no account (`invite_peek`, `GET /v1/invites/{token}`). Above the choice: "{inviter} invited {email} to join {team} as a member. Create your account with that address, or sign in if you have one: Teams then opens with the invite." The choice starts on **New account** with the server and address filled in. Without a link, **Joining a team? Paste your invite** takes the code, the invite page's address or a `morphgate://invite` link (`links_add_invite`) and does the same; a bare code is looked up on morphgate.com, quietly. Once there's an account, the workspace takes the invite and opens Teams with it looked up ([Deep links](deep-links.md)).
+
+**Suggested passwords.** **Suggest a strong password** (in `NewPasswordFields`, so also for recovery and password change) asks `password_suggest`: five words picked with the system's random generator from EFF's long wordlist (`src-tauri/wordlist/`, 7772 words without the four that contain a hyphen, about 64 bits), joined by hyphens, drawn again in the rare case the strength check would refuse them. Both fields get it, and it shows in the clear under the meter ("Five random words. Write them down, or save them in a password manager: you'll need them to unlock MorphGate.") with **Suggest another**, until either field changes. Never a fixed example: those end up in cracking wordlists.
## Master password rules
@@ -125,7 +130,7 @@ The timer is the webview's (`useIdleLock` in `App.tsx`): `keydown`, `mousedown`,
The code is shown once, at creation and when a new one is made (`RecoveryCode`: copy, the "I stored it somewhere safe" checkbox, then the action button). Neither the key nor the code is stored.
-**Forgot the password, on this device.** **Forgot password? Use your recovery key** opens **Use your recovery key**: **Recovery key** (a text area), **Master password**, **Repeat password**, **Set new password** ("Re-encrypting…"), **Back to unlock**. The footnote says whether sync is on. `vault_recover` checks the new password, then without sync `LocalVault::recover`: parse the code, unwrap the account keys from the local `wrapped_private_keys_rk`, check they are this vault's ("that recovery key does not match this vault"), wrap them under the new password with fresh KDF parameters, and unlock. With sync the server goes first (`mg_store::account::recover`). On a fresh device, recovery goes through **I have an account**, **Forgot the master password? Use your recovery key** ("Reset password and sign in", **Reset and sign in**).
+**Forgot the password, on this device.** **Forgot password? Use your recovery key** opens **Use your recovery key**: **Recovery key** (a text area), **Master password**, **Repeat password**, **Set new password** ("Re-encrypting…"), **Back to unlock**. The footnote says whether sync is on. `vault_recover` checks the new password, then without sync `LocalVault::recover`: parse the code, unwrap the account keys from the local `wrapped_private_keys_rk`, check they are this vault's ("that recovery key does not match this vault"), wrap them under the new password with fresh KDF parameters, and unlock. With sync the server goes first (`mg_store::account::recover`). On a fresh device, recovery goes through **Sign in**, **Forgot the master password? Use your recovery key** ("Reset password and sign in", **Reset and sign in**).
**A new recovery key.** Security, **Recovery key**: **Master password**, **Create a new recovery key** ("Creating…"). `vault_new_recovery_code` makes a new key and replaces `wrapped_private_keys_rk` and `recovery_proof`; the password wrapping stays. The old code stops working at once (on the server too, with sync on). Nobody is signed out.
diff --git a/docs/features/vault-crypto.md b/docs/features/vault-crypto.md
index d0694e5..9f1cffe 100644
--- a/docs/features/vault-crypto.md
+++ b/docs/features/vault-crypto.md
@@ -149,6 +149,7 @@ The revision memory lives in `mg-store`: `vaults.head_rev` is the highest server
| --- | --- |
| A pull answers with `head_rev` below the device's | `SyncError::Rollback`: "the sync server sent older data (revision R) than this device already has (L); refusing to roll back". Nothing from that answer is applied |
| A pulled item whose `rev` is not newer than the local copy's | Skipped (`apply_remote`) |
+| A pulled item or deletion whose version (inside its signed envelope) is below the last one the server confirmed for it | `Error::OlderItem`: "the sync server sent an older copy of item … than this device already has …; refusing to roll it back". Nothing from that page is applied ([Item history](sync.md#item-history)) |
| `410 Gone` (the history was purged) | The cursor resets to 0 and the vault is pulled in full; the `head_rev` check is off for that pass, the per-item check stays. Synced, unchanged items the server no longer lists are dropped (`drop_unseen`) |
| Sync turned off | `head_rev` back to 0 for every vault: another server is another history |
@@ -172,7 +173,8 @@ How a round pages and resolves conflicts: [Sync](sync.md).
| --- | --- |
| `mg-vault` unit tests, one module each | Derivation determinism and domain separation, weak parameters refused (`kdf.rs`); wrap, wrong password, recovery and device domains, truncated blobs (`account.rs`); code shape, forgiving input, typos (`recovery_code.rs`); seal, other account, tampering, low-order keys (`vault.rs`); bucket sizes, trailing zeros, moved items, bit flips, wrong author, wrong key, oversize (`envelope.rs`); binding to vault and kind, SHA-256 vector, size limit (`blob.rs`); fixed vectors computed independently (`sharing.rs`) |
| `mg-store` `vault.rs` tests | `persists_and_stores_only_ciphertext` (no hostname, password or key bytes in the file), `tampered_or_swapped_envelopes_are_rejected`, `another_accounts_keys_are_refused` |
-| `mg-store` `sync.rs` tests | `rollback_is_refused` |
+| `mg-store` `sync.rs` tests | `rollback_is_refused`, `an_older_copy_of_an_item_is_refused`, `a_deleted_item_cant_be_brought_back` |
+| `mg-store` `history.rs` tests | The plaintext with its version, deletions, items from older apps and newer kinds |
`cargo test -p mg-vault -p mg-store`. Tests derive at the floor parameters (`fast_params()`, `LocalVault::with_kdf_cost(MIN_M_KIB, MIN_T, 1)`), 64 MiB each. CI runs `cargo test --workspace --all-features` on Linux and these crates on macOS and Windows.
diff --git a/docs/features/vpn.md b/docs/features/vpn.md
index 8f73a0f..3a2a5c4 100644
--- a/docs/features/vpn.md
+++ b/docs/features/vpn.md
@@ -74,7 +74,7 @@ Field and certificate prompts time out after 5 minutes (`PROMPT_TIMEOUT`), which
## The VPN editor
-`VpnPanel` edits one profile; the VPNs page (`VpnsPage` in `Lists.tsx`, **New VPN**) lists them with their server and status, or what uses them ("Used by 2 hosts and 1 group", "Not used by a host yet").
+`VpnPanel` edits one profile; the VPNs page (`VpnsPage` in `Lists.tsx`, **New VPN**) lists them as a table: name and kind, server, what uses them ("2 hosts, 1 group", "No host yet") and their state ("Up", "Down", or "Problem" with the problem under the name).
| Control | Does |
| --- | --- |
diff --git a/docs/sync-crypto-spec.md b/docs/sync-crypto-spec.md
index 24f8734..f96a7f1 100644
--- a/docs/sync-crypto-spec.md
+++ b/docs/sync-crypto-spec.md
@@ -24,13 +24,13 @@ A fully compromised server, ours or a self-hoster's, learns metadata only: accou
| Adversary | Can | Cannot |
| --- | --- | --- |
-| Malicious / breached server | Read ciphertext and metadata, drop or replay old blobs, deny service | Decrypt items, forge items (AEAD + signatures), make a client accept a vault revision below one it has seen |
+| Malicious / breached server | Read ciphertext and metadata, drop or replay old blobs, deny service | Decrypt items, forge items (AEAD + signatures), make a client accept a vault revision below one it has seen, or a signed copy of an item older than one it has seen ([item history](#sync-protocol)) |
| Network attacker | Nothing beyond TLS failure | Anything: TLS required (the client refuses plain `http://` except to this computer), plus E2E |
| Stolen unlocked device | Everything in that vault | Survive revocation for future changes |
| Stolen locked device | Offline brute-force of master password | Succeed quickly (Argon2id, high cost) |
| Malicious self-host operator | Same as breached server | Same as breached server |
-**Rollback defense.** Each client remembers the highest vault revision it has seen. A server answering with a lower one is flagged as an error, not silently accepted. The rollback guard covers the vault's revision, not each item's history.
+**Rollback defense.** Each client remembers the highest vault revision it has seen. A server answering with a lower one is flagged as an error, not silently accepted. Items have a guard of their own: every write carries a version inside its signed envelope, deletions are signed, and a client refuses a copy of an item older than one it has seen (Item history, under Sync protocol).
**KDF bounds.** KDF parameters come from the server at prelogin, so the client enforces floors (≥ 64 MiB, ≥ 2 passes, ≥ 16-byte salt) and refuses to derive below them, and ceilings (≤ 1 GiB, ≤ 10 passes, ≤ 64-byte salt), so a server can't make a device allocate or compute without bound.
@@ -96,6 +96,8 @@ All HKDF is HKDF-SHA256; all info strings are prefixed `morphgate/v1/`.
| Blob | `0x01 ‖ key_id(16) ‖ nonce(24) ‖ XChaCha20-Poly1305(VK, aad, pad₄ₖ(plaintext))`, aad = `blob ‖ u16 len ‖ vault_id ‖ u16 len ‖ kind ‖ 0x01 ‖ key_id`; named by lowercase hex SHA-256 of the whole blob; no signature (the signed item that refers to it holds the hash) |
| Envelope AAD | `u16 len ‖ vault_id ‖ u16 len ‖ item_id ‖ u16 schema_version ‖ 0x01 ‖ key_id` |
| Envelope signature | Ed25519 over `envelope-sig ‖ u32 len(aad) ‖ aad ‖ version ‖ key_id ‖ nonce ‖ ct`; verified before decrypting |
+| Item plaintext | The item's JSON (Data model) with `item_version`: the time of the write in Unix milliseconds, or one more than the highest version the writer knows of the item when that is larger |
+| Deletion | An envelope of the deleted item (same AAD) whose plaintext is `{"type":"deleted","item_version":n}` |
| Padding | ISO/IEC 7816-4 (`0x80` then zeros) to a multiple of 256 bytes |
| Vault `meta` | An envelope with item id `vault-meta` (JSON `{name, directory?}`), under the vault key, signed by the vault's creator (`created_by`) |
@@ -132,21 +134,21 @@ The server knows three things: accounts, vaults and items. Items are opaque enve
**Plaintext item types (client-only schema, `schema_version` 1)**
-Serialized as JSON with a `type` tag, then sealed into an envelope.
+Serialized as JSON with a `type` tag and the write's `item_version` (Item history), then sealed into an envelope.
| Type | Fields | Status |
| --- | --- | --- |
-| `host` | label, hostname, port, user, auth (`password {password?}` \| `key {key_ref}` \| `agent`), jump_ref? (host to tunnel through, chains allowed, no loops, ≤ 8 deep), vpn_ref? (VPN for the first hop), theme_ref? (a `terminal_theme` in the same vault, or `builtin:` for one built into the app), record_sessions?, keep_session? (attach to a tmux session on the server), group_ref? (a `host_group`), identity_ref? (an `identity`, instead of user and auth; user may then be empty), tags, notes | Implemented |
+| `host` | label, hostname, port, user, auth (`password {password?}` \| `key {key_ref}` \| `agent`), jump_ref? (host to tunnel through, chains allowed, no loops, ≤ 8 deep), vpn_ref? (VPN for the first hop), theme_ref? (a `terminal_theme` in the same vault, or `builtin:` for one built into the app), record_sessions?, keep_session? (attach to a tmux session on the server), group_ref? (a `host_group`), identity_ref? (an `identity`, instead of user and auth; user may then be empty), tags, notes, source? (the `cloud_source` that keeps it: source_ref, external_id, label, gone?, state?, host_keys? (its server's keys, listed by a feed), tags? (the ones the feed gave it)) | Implemented |
| `vpn` | label, vpn (`wireguard {config}`: wg-quick text incl. private key) | Implemented |
| `ssh_key` | label, private_key (OpenSSH/PEM, as imported), passphrase?, public_key, algorithm, fingerprint | Implemented |
-| `snippet` | name, body, tags | Implemented (storage only) |
+| `snippet` | name, body, tags, source? (the feed that keeps it: source_ref, external_id) | Implemented (storage only) |
| `identity` | label, user, auth (as a host's) — shared credentials hosts refer to | Implemented |
| `host_group` | name, identity_ref?, jump_ref?, vpn_ref?, theme_ref?, keep_session? — a host takes what it leaves empty from its group (credentials only when its user is empty); deleting a group makes those values the hosts' own | Implemented |
| `attachment` | name, host_ref?, blob, size (see Blobs) | Implemented |
| `session_log` | see Blobs | Implemented |
| `port_forward` | label, host_ref, rule (`local` \| `remote` {bind_host, bind_port, target_host, target_port} \| `dynamic` {bind_host, bind_port}) | Implemented |
| `known_host` | host pattern, key type, public key | Planned (today: local known_hosts file) |
-| `cloud_source` | label, provider, endpoint?, key_ref?, account — a cloud account whose machines become hosts, kept in step with it ([cloud sources](features/cloud-sources.md)) | Implemented |
+| `cloud_source` | label, provider, endpoint?, key_ref?, account, url?, token? — a cloud account or feed whose machines become hosts, kept in step with it ([cloud sources](features/cloud-sources.md)) | Implemented |
| `host_ca` | label, public_key, hosts (patterns) — a CA whose signed host keys are trusted for those hosts (`@cert-authority`) | Implemented |
| `terminal_theme` | name, background, foreground, cursor, cursor_accent, selection_background, selection_foreground?, ansi (16 colors), all `#rrggbb`; imported from Ghostty, Alacritty, iTerm2 or Windows Terminal theme files | Implemented |
| `member_key` | account_id, public_key (OpenSSH `type base64`), label, sig (member key signature, hex) — only in an organization's directory vault | Implemented |
@@ -162,7 +164,7 @@ The client is offline-first: the vault exists on the device before any server is
- One SQLite file in the app data dir (`vault.sqlite3`, mode 0600, `secure_delete` on).
- `account`: KDF params, public keys, `wrapped_private_keys`, `wrapped_private_keys_rk` (same values the server gets at register).
- `vaults`: id, `key_id`, `wrapped_vault_key`, `head_rev` (highest server revision seen, for the rollback guard).
-- `items`: id, vault_id, envelope (NULL for tombstones), `deleted`, `rev` (server rev the copy is based on; 0 = never synced), `dirty` (unpushed local change), `updated_at`.
+- `items`: id, vault_id, envelope (NULL for tombstones, except a deletion made on this device, which keeps its signed envelope), `deleted`, `rev` (server rev the copy is based on; 0 = never synced), `dirty` (unpushed local change), `updated_at`, `version` (the one in the envelope) and `seen_version` (the one of the last copy the server confirmed).
- Nothing typed is stored in plaintext: hostnames, users, passwords and keys exist only inside envelopes.
- Unlock = Argon2id + unwrap account keys + open vault key, all in memory. Lock drops every key and closes open sessions.
- Optional per device: a random device key in the OS keychain (`device_unlock` holds the account keys wrapped under HKDF(device key, `device-kek`)). It unlocks without the master password, survives password changes and recovery, never syncs, and is tried automatically only at app launch. The device can also require Touch ID, Windows Hello or polkit (Linux) before the key is read. That is a check made by the app: it stops someone at an unlocked computer, not malware running as the user.
@@ -175,7 +177,7 @@ The client is offline-first: the vault exists on the device before any server is
Sync is a per-vault revision log. Pull is "give me everything after rev N". Push is a batch with optimistic concurrency per item. Conflicts resolve on the client, because only the client can read the data.
1. **Pull.** `GET /vaults/{id}/changes?since={rev}&limit=500` returns items with `rev > since` (tombstones included) plus `head_rev`. Page until `has_more = false`.
-2. **Decrypt & apply.** Verify signature, decrypt, merge into local SQLite. Store `last_rev`.
+2. **Decrypt & apply.** Verify signature, decrypt, check the item's version (Item history), merge into local SQLite. Store `last_rev`.
3. **Push.** `POST /vaults/{id}/changes` with a batch of `{id, base_rev, deleted, envelope}`.
- Server accepts an item if its current rev equals `base_rev`, or the item is new.
- Otherwise it rejects that item with `conflict` in the per-item result and returns the current server copy.
@@ -184,7 +186,29 @@ Sync is a per-vault revision log. Pull is "give me everything after rev N". Push
5. **Realtime nudge (optional).** `GET /vaults/{id}/events` via SSE sends only `{head_rev}`. Clients then pull. Self-hosters can skip this; clients fall back to polling: every 60 s while someone uses the app, every 5 minutes while nobody does (window hidden, or no input for 5 minutes), and at once on coming back. Local edits push within seconds either way.
6. **Quiet rounds are cheap.** A round lists the account's organizations (`GET /orgs`) and vaults (`GET /vaults`). A vault whose `head_rev` there equals the device's `last_rev` isn't pulled. An organization whose `rev` (optional) equals the one the device saw last isn't fetched again: its details, members, device approvals and vault members are reused, and verified again locally (the device's own pins may have changed). So a quiet round is two requests, however many organizations and vaults there are.
-**Tombstones.** Deletes are items with `deleted: true` and an empty envelope. The server keeps them for at least 90 days. A client whose `last_rev` is older than the oldest retained tombstone gets `410 Gone` and does a full resync.
+**Tombstones.** Deletes are items with `deleted: true`. A client with item history sends the deletion's signed envelope along (at most 1 KiB, under any key generation of the vault); a server with `features.signed_tombstones` keeps it for an item it has and returns it with the tombstone, and others ignore it. The server keeps tombstones for at least 90 days. A client whose `last_rev` is older than the oldest retained tombstone gets `410 Gone` and does a full resync.
+
+**Item history.** The vault's revision only says how far a device has read. Each item also has a
+version inside its signed envelope, so a server can't serve a device a copy of an item older
+than one it has seen:
+
+- Every write carries `item_version` (Wire formats): an edit, a conflict copy, a re-seal under a
+ rotated key, a deletion. Versions grow along an item's history because a server takes a write
+ only on the item's latest revision (`base_rev`); the clock keeps them growing for an item made
+ again after its tombstone was purged.
+- A client remembers the version of the last copy of each item the server confirmed (pulled or
+ pushed). A signed copy with a lower version, item or deletion, is refused: the vault's sync
+ stops with an error, as for a vault revision below the one seen. An equal version is taken (the
+ same write again, or an older app's re-seal of it, which keeps the plaintext).
+- Apps from before item history (0.2.0) write items without `item_version` and delete without an
+ envelope, and ignore both in what they read. A client takes such a change, and the next
+ versioned copy starts the count again (its writer may not have seen the earlier ones). So the
+ guard holds once every app that writes to a vault has item history. Strict item history, a
+ client setting, refuses both kinds of change to an item that has a version; it is off while
+ such apps may still write.
+- Not covered: a client without a copy of an item has nothing to compare it with (a new device,
+ or one starting over after `410`), so a server can still serve it an old copy or leave an item
+ out. That needs signed checkpoints of a vault's state.
**Blobs and session logs.** What is too big for an item goes in a blob
(`/vaults/{id}/blobs/{hash}`, behind `features.blobs`): sealed under the vault key (Wire formats),
@@ -224,7 +248,10 @@ Login proves knowledge of `AuthKey` and returns short-lived tokens. The client n
**Devices.** Each login registers a device (`name`, `platform`, public Ed25519 key). `GET /devices` lists them and `DELETE /devices/{id}` signs one out: its tokens stop working, and its next refresh gets `401 device_revoked` (servers keep answering that for the refresh token's lifetime), on which the app erases what it keeps (vaults, keys, settings, pinned host keys, its keychain entry) and returns to first run with a note saying why. Only that code erases. `401 account_deleted` (the account was deleted from another device or a website) turns sync off as *Disconnect → Keep a copy* does: the personal vault stays, now the only copy, and team vaults go. Every other 401 (an expired session, a reused refresh token, a password changed on another device) means "sign in again", and the data stays. A device that never connects again keeps what it has, encrypted under the master password, so the UI says signing out takes effect when the device next connects. A device's own *Disconnect* asks whether to erase it too or keep a copy that no longer syncs.
**2FA.** TOTP on the account, checked at `/auth/login`; set up and turned off through
-`/account/totp…`, and turned off by recovery. WebAuthn isn't supported yet. Email-verified sign-up,
+`/account/totp…`, and turned off by recovery. An organization can require it
+(`policies.require_totp`): its vaults answer `403 totp_setup_required` (with `org_id`) to a member
+without it, and `GET /vaults` leaves them out, so the app checks the account when none of such an
+organization's vaults is listed. WebAuthn isn't supported yet. Email-verified sign-up,
email changes and an account portal are optional features of the API spec
([`spec/openapi.yaml`](../spec/openapi.yaml)).
@@ -303,7 +330,7 @@ We publish `vault-sync/v1` as an OpenAPI 3.1 document plus a conformance test su
| [`tools/vault-conform`](../tools/vault-conform) (runs against a URL) | Apache-2.0 OR MIT | Pass/fail per endpoint, with throwaway accounts: auth, devices, sync (conflicts, 410, pagination), sharing, blobs |
| Reference server ([`server/`](../server), Rust + SQLite, single binary, Dockerfile) | AGPL-3.0 | So self-hosting is `docker run`, not "implement the spec" |
-**Capability discovery.** `GET /.well-known/vault-sync`, which needs no session (like the sign-up and sign-in steps), returns the spec version, limits, and optional features (`Capabilities` in the spec: `registration_open`, `terms`, `email_verification`, `sse`, `totp`, `blobs`, `sso`, `sharing`, `billing`). The client adapts its UI from that response; for example, it shows no plans when `billing` is absent.
+**Capability discovery.** `GET /.well-known/vault-sync`, which needs no session (like the sign-up and sign-in steps), returns the spec version, limits, and optional features (`Capabilities` in the spec: `registration_open`, `terms`, `email_verification`, `sse`, `totp`, `signed_tombstones`, `blobs`, `sso`, `sharing`, `billing`). The client adapts its UI from that response; for example, it shows no plans when `billing` is absent.
**Versioning.** The path prefix is `/v1`. Additive changes are minor and must be ignored by older clients. Breaking changes go to `/v2`, and the client supports N and N-1.
diff --git a/scripts/upload-ios.py b/scripts/upload-ios.py
index a546dd2..381f1db 100755
--- a/scripts/upload-ios.py
+++ b/scripts/upload-ios.py
@@ -7,6 +7,10 @@
The App Store Connect API key: ASC_KEY_ID and ASC_ISSUER_ID, and the .p8 at ASC_KEY_PATH
(default ~/.apple/AuthKey_.p8). It needs the Admin or App Manager role. Needs PyJWT
with cryptography.
+
+Apple's API and its upload storage drop a request now and then: requests that can be repeated
+are, after a pause. A build App Store Connect has already (an earlier run that got through,
+though its answer didn't) counts as uploaded.
"""
import hashlib
import json
@@ -25,6 +29,8 @@
os.environ.get("ASC_KEY_PATH") or pathlib.Path.home() / ".apple" / f"AuthKey_{KEY_ID}.p8"
).read_text()
API = "https://api.appstoreconnect.apple.com"
+# Pauses before each further try of a request that can be repeated.
+PAUSES = (5, 15, 30, 60)
def token():
@@ -37,19 +43,36 @@ def token():
)
-def api(method, path, body=None):
- req = urllib.request.Request(
- API + path,
- data=json.dumps(body).encode() if body is not None else None,
- method=method,
- headers={"Authorization": f"Bearer {token()}", "Content-Type": "application/json"},
- )
- try:
- with urllib.request.urlopen(req, timeout=60) as r:
- raw = r.read()
- return json.loads(raw) if raw else {}
- except urllib.error.HTTPError as e:
- sys.exit(f"{method} {path}: {e.code} {e.read().decode()[:1500]}")
+def send(make, what, timeout, repeat):
+ """Sends the request `make()` builds; a repeatable one again after a dropped connection, a
+ timeout or a 5xx answer. Returns the body."""
+ for pause in (*PAUSES, None) if repeat else (None,):
+ try:
+ with urllib.request.urlopen(make(), timeout=timeout) as r:
+ return r.read()
+ except urllib.error.HTTPError as e:
+ if e.code < 500 or pause is None:
+ sys.exit(f"{what}: {e.code} {e.read().decode()[:1500]}")
+ why = f"HTTP {e.code}"
+ except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
+ if pause is None:
+ sys.exit(f"{what}: {e}")
+ why = str(e)
+ print(f"{what}: {why}; again in {pause} s")
+ time.sleep(pause)
+
+
+def api(method, path, body=None, timeout=60, repeat=True):
+ def make():
+ return urllib.request.Request(
+ API + path,
+ data=json.dumps(body).encode() if body is not None else None,
+ method=method,
+ headers={"Authorization": f"Bearer {token()}", "Content-Type": "application/json"},
+ )
+
+ raw = send(make, f"{method} {path}", timeout, repeat)
+ return json.loads(raw) if raw else {}
ipa, bundle_id, version, build = sys.argv[1:5]
@@ -60,11 +83,19 @@ def api(method, path, body=None):
sys.exit(f"no App Store Connect app for {bundle_id}: create it first (Apps → + → New App)")
app = apps[0]["id"]
+existing = api("GET", f"/v1/builds?filter%5Bapp%5D={app}&filter%5Bversion%5D={build}"
+ f"&filter%5BpreReleaseVersion.version%5D={version}&limit=1")["data"]
+if existing:
+ state = existing[0]["attributes"].get("processingState")
+ print(f"{version} ({build}) is in App Store Connect already ({state}): nothing to upload")
+ sys.exit(0)
+
+# Not repeated: a retry after a lost answer could start a second upload of the same build.
upload = api("POST", "/v1/buildUploads", {"data": {
"type": "buildUploads",
"attributes": {"cfBundleShortVersionString": version, "cfBundleVersion": build, "platform": "IOS"},
"relationships": {"app": {"data": {"type": "apps", "id": app}}},
-}})["data"]
+}}, repeat=False)["data"]
print("build upload", upload["id"])
file = api("POST", "/v1/buildUploadFiles", {"data": {
@@ -72,22 +103,21 @@ def api(method, path, body=None):
"attributes": {"fileName": pathlib.Path(ipa).name, "fileSize": len(data),
"uti": "com.apple.ipa", "assetType": "ASSET"},
"relationships": {"buildUpload": {"data": {"type": "buildUploads", "id": upload["id"]}}},
-}})["data"]
+}}, repeat=False)["data"]
ops = file["attributes"]["uploadOperations"]
print(f"uploading {len(data)} bytes in {len(ops)} parts")
for op in ops:
part = data[op["offset"]:op["offset"] + op["length"]]
- req = urllib.request.Request(op["url"], data=part, method=op["method"],
- headers={h["name"]: h["value"] for h in op.get("requestHeaders") or []})
- with urllib.request.urlopen(req, timeout=300) as r:
- if r.status >= 300:
- sys.exit(f"part {op.get('partNumber')}: HTTP {r.status}")
+ headers = {h["name"]: h["value"] for h in op.get("requestHeaders") or []}
+ send(lambda: urllib.request.Request(op["url"], data=part, method=op["method"], headers=headers),
+ f"part {op.get('partNumber')}", 300, True)
+# Apple checks the whole file before it answers: give it time.
api("PATCH", f"/v1/buildUploadFiles/{file['id']}", {"data": {
"type": "buildUploadFiles", "id": file["id"],
"attributes": {"uploaded": True, "sourceFileChecksums": {
"file": {"hash": hashlib.md5(data).hexdigest(), "algorithm": "MD5"}}},
-}})
+}}, timeout=300)
print("uploaded; Apple is processing it")
for _ in range(120):
diff --git a/server/src/api.rs b/server/src/api.rs
index dcc1d74..de5a22a 100644
--- a/server/src/api.rs
+++ b/server/src/api.rs
@@ -221,6 +221,7 @@ async fn capabilities(State(s): State) -> Json {
sso: Some(SsoFeatures {
protocols: vec!["oidc".into()],
}),
+ signed_tombstones: true,
},
})
}
@@ -915,16 +916,20 @@ async fn push(
results.push(reject("bad_request", "item id must be a ULID"));
continue;
}
- let envelope = if item.deleted {
- None
- } else {
- item.envelope.map(|e| e.0)
- };
+ // A deletion may carry its author's signed deletion: kept and returned with it.
+ let envelope = item.envelope.map(|e| e.0);
match &envelope {
None if !item.deleted => {
results.push(reject("bad_request", "envelope required"));
continue;
}
+ Some(e) if item.deleted && e.len() > MAX_DELETION_ENVELOPE => {
+ results.push(reject(
+ "too_large",
+ "a deletion's envelope is at most 1024 bytes",
+ ));
+ continue;
+ }
Some(e) if e.len() > MAX_ENVELOPE => {
results.push(reject("too_large", "envelope exceeds max_envelope_bytes"));
continue;
@@ -934,7 +939,8 @@ async fn push(
results.push(reject("bad_request", "envelope too short"));
continue;
}
- Some(e) if e[1..17] != current_key[..] => {
+ // A deletion carries no secret: any key generation the vault had will do.
+ Some(e) if !item.deleted && e[1..17] != current_key[..] => {
results.push(reject(
"stale_key",
"the vault has a newer key: re-encrypt and push again",
@@ -960,6 +966,9 @@ async fn push(
},
)
.optional()?;
+ // A deletion's envelope only for an item the server has: for one it never had, it would
+ // take room that no quota counts.
+ let envelope = envelope.filter(|_| !(item.deleted && current.is_none()));
if let Some(cur) = current.filter(|cur| cur.rev != item.base_rev) {
results.push(PushResult {
id: item.id,
@@ -971,7 +980,10 @@ async fn push(
continue;
}
head += 1;
- let key_id = envelope.as_ref().map(|e| e[1..17].to_vec());
+ let key_id = envelope
+ .as_ref()
+ .filter(|_| !item.deleted)
+ .map(|e| e[1..17].to_vec());
tx.execute(
"INSERT INTO items (vault_id, id, rev, deleted, device_id, envelope, updated_at, author_id, key_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
diff --git a/server/tests/protocol.rs b/server/tests/protocol.rs
index d4e1098..62c648f 100644
--- a/server/tests/protocol.rs
+++ b/server/tests/protocol.rs
@@ -186,6 +186,43 @@ async fn push_pull_conflicts_and_tombstones() {
let tomb = page.items.iter().find(|i| i.id == b).unwrap();
assert!(tomb.deleted && tomb.envelope.is_none());
+ // A signed deletion: its envelope is kept and comes back (any key generation, at most 1 KiB).
+ let signed = ulid();
+ c.push(&vault, vec![put(&signed, 0, 5)]).await.unwrap();
+ let deletion = |base_rev, len| PushItem {
+ id: signed.clone(),
+ base_rev,
+ deleted: true,
+ envelope: Some(bytes(len, 7)),
+ };
+ let r = c.push(&vault, vec![deletion(5, 1025)]).await.unwrap();
+ assert_eq!(r.results[0].status, PushStatus::Rejected);
+ let r = c.push(&vault, vec![deletion(5, 400)]).await.unwrap();
+ assert_eq!(r.results[0].status, PushStatus::Accepted);
+ let page = c.pull(&vault, 5, 500).await.unwrap();
+ let tomb = page.items.iter().find(|i| i.id == signed).unwrap();
+ assert!(tomb.deleted);
+ assert_eq!(tomb.envelope, Some(bytes(400, 7)));
+ assert!(c.capabilities().await.unwrap().features.signed_tombstones);
+ // An item the server never had: its deletion keeps no envelope.
+ let unknown = ulid();
+ let r = c
+ .push(
+ &vault,
+ vec![PushItem {
+ id: unknown.clone(),
+ base_rev: 0,
+ deleted: true,
+ envelope: Some(bytes(400, 7)),
+ }],
+ )
+ .await
+ .unwrap();
+ assert_eq!(r.results[0].status, PushStatus::Accepted);
+ let page = c.pull(&vault, 6, 500).await.unwrap();
+ assert_eq!(page.items.len(), 1);
+ assert!(page.items[0].deleted && page.items[0].envelope.is_none());
+
// Pagination.
let page = c.pull(&vault, 0, 1).await.unwrap();
assert!(page.has_more && page.items.len() == 1);
@@ -207,7 +244,7 @@ async fn push_pull_conflicts_and_tombstones() {
assert!(r.results.iter().all(|x| x.status == PushStatus::Rejected));
let v = c.list_vaults().await.unwrap();
- assert_eq!((v.len(), v[0].head_rev, v[0].item_count), (1, 4, Some(1)));
+ assert_eq!((v.len(), v[0].head_rev, v[0].item_count), (1, 7, Some(1)));
}
#[tokio::test]
diff --git a/server/tests/totp_policy.rs b/server/tests/totp_policy.rs
new file mode 100644
index 0000000..c2d4ed9
--- /dev/null
+++ b/server/tests/totp_policy.rs
@@ -0,0 +1,310 @@
+//! A team that requires two-step verification (`require_totp`), as the hosted service enforces
+//! it: the reference server has no two-step verification, so a proxy in front of it plays that
+//! part for one member. Without it on the account, the team's vaults are left out of the list
+//! and refused (`403 totp_setup_required`); the round says why, and a vault already on the device
+//! stays there.
+
+use std::collections::HashSet;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{Arc, Mutex};
+
+use axum::body::Body;
+use axum::extract::{Request, State};
+use axum::http::{HeaderMap, StatusCode, header};
+use axum::response::Response;
+use mg_store::account::SharedVault;
+use mg_store::sharing;
+use mg_store::sync::{Hold, SyncConfig, sync_once};
+use mg_store::sync_http::{HttpRemote, create_vault_request, register_request};
+use mg_store::{Access, Host, HostAuth, ItemData, LocalVault};
+use mg_sync::{B64, DeviceInfo, Role, SyncClient};
+use mg_vault::KdfParams;
+use serde_json::{Value, json};
+
+mod common;
+
+const PW: &str = "correct horse battery";
+
+struct Device {
+ vault: SharedVault,
+ client: Arc,
+ account: String,
+ email: String,
+}
+
+/// The hosted service's check, for whoever syncs through it: `totp` is whether their account
+/// has two-step verification on.
+#[derive(Clone)]
+struct Gate {
+ upstream: String,
+ http: reqwest::Client,
+ org: Arc>,
+ vaults: Arc>>,
+ totp: Arc,
+}
+
+impl Gate {
+ async fn serve(upstream: &str) -> (String, Gate) {
+ let gate = Gate {
+ upstream: upstream.into(),
+ http: reqwest::Client::new(),
+ org: Arc::default(),
+ vaults: Arc::default(),
+ totp: Arc::default(),
+ };
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let url = format!("http://{}", listener.local_addr().unwrap());
+ let app = axum::Router::new()
+ .fallback(forward)
+ .with_state(gate.clone());
+ tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
+ (url, gate)
+ }
+
+ fn on(&self) -> bool {
+ self.totp.load(Ordering::SeqCst)
+ }
+}
+
+/// Forwards to the reference server, then answers as the hosted service does for an
+/// organization that requires two-step verification.
+async fn forward(State(g): State, req: Request) -> Response {
+ let method = req.method().clone();
+ let path = req.uri().path().to_owned();
+ let target = format!(
+ "{}{}",
+ g.upstream,
+ req.uri().path_and_query().map_or("", |p| p.as_str())
+ );
+ let mut headers = HeaderMap::new();
+ for name in [header::AUTHORIZATION, header::CONTENT_TYPE, header::ACCEPT] {
+ if let Some(v) = req.headers().get(&name) {
+ headers.insert(name, v.clone());
+ }
+ }
+ let body = axum::body::to_bytes(req.into_body(), usize::MAX)
+ .await
+ .unwrap();
+ let org = g.org.lock().unwrap().clone();
+ let vault = path
+ .strip_prefix("/v1/vaults/")
+ .and_then(|rest| rest.split('/').next());
+ if !g.on() && vault.is_some_and(|v| g.vaults.lock().unwrap().contains(v)) {
+ return json_response(
+ StatusCode::FORBIDDEN,
+ &json!({
+ "code": "totp_setup_required",
+ "message": "this organization requires two-step verification; turn it on first",
+ "org_id": org,
+ }),
+ );
+ }
+ let resp = g
+ .http
+ .request(method.clone(), target)
+ .headers(headers)
+ .body(body)
+ .send()
+ .await
+ .unwrap();
+ let status = resp.status();
+ let bytes = resp.bytes().await.unwrap();
+ if method != axum::http::Method::GET || !status.is_success() {
+ return Response::builder()
+ .status(status)
+ .header(header::CONTENT_TYPE, "application/json")
+ .body(Body::from(bytes))
+ .unwrap();
+ }
+ let mut value: Value = match serde_json::from_slice(&bytes) {
+ Ok(v) => v,
+ Err(_) => {
+ return Response::builder()
+ .status(status)
+ .body(Body::from(bytes))
+ .unwrap();
+ }
+ };
+ if path == "/v1/vaults" && !g.on() {
+ // Left out of the list, without a word.
+ if let Some(list) = value.as_array_mut() {
+ list.retain(|v| v["org_id"].as_str() != Some(&org));
+ }
+ } else if !org.is_empty() && path == format!("/v1/orgs/{org}") {
+ value["policies"]["require_totp"] = json!(true);
+ } else if path == "/v1/account" {
+ value["totp_enabled"] = json!(g.on());
+ }
+ json_response(status, &value)
+}
+
+fn json_response(status: StatusCode, value: &Value) -> Response {
+ Response::builder()
+ .status(status)
+ .header(header::CONTENT_TYPE, "application/json")
+ .body(Body::from(serde_json::to_vec(value).unwrap()))
+ .unwrap()
+}
+
+fn fast() -> LocalVault {
+ LocalVault::open_in_memory()
+ .unwrap()
+ .with_kdf_cost(KdfParams::MIN_M_KIB, KdfParams::MIN_T, 1)
+}
+
+fn host(label: &str) -> ItemData {
+ ItemData::Host(Host {
+ label: label.into(),
+ hostname: format!("{label}.internal"),
+ port: 22,
+ user: "deploy".into(),
+ auth: HostAuth::Password { password: None },
+ jump_ref: None,
+ vpn_ref: None,
+ theme_ref: None,
+ record_sessions: false,
+ tags: vec![],
+ notes: String::new(),
+ group_ref: None,
+ identity_ref: None,
+ keep_session: false,
+ forward_agent: false,
+ login_command: None,
+ jump_command: None,
+ proxy_command: None,
+ source: None,
+ })
+}
+
+/// Someone's first device: a new account with sync on, through `url`.
+async fn first(url: &str, name: &str) -> Device {
+ let mut v = fast();
+ v.create(PW).unwrap();
+ let (export, _) = v.export_for_registration(PW).unwrap();
+ let client = Arc::new(SyncClient::new(url).unwrap());
+ let email = common::email(name);
+ let device = DeviceInfo {
+ id: ulid::Ulid::generate().to_string(),
+ name: name.into(),
+ platform: "linux".into(),
+ client_version: None,
+ public_key: B64(vec![1; 32]),
+ };
+ let session = client
+ .register(®ister_request(&export, &email, device.clone(), None))
+ .await
+ .unwrap();
+ client
+ .create_vault(&create_vault_request(&export.vault))
+ .await
+ .unwrap();
+ v.enable_sync(&SyncConfig {
+ server_url: url.into(),
+ email: email.clone(),
+ account_id: session.account_id.clone(),
+ device_id: device.id,
+ device_public_key: [1; 32],
+ })
+ .unwrap();
+ Device {
+ vault: Arc::new(Mutex::new(v)),
+ client,
+ account: session.account_id,
+ email,
+ }
+}
+
+/// One round: the shared part, then the vaults.
+async fn round(d: &Device) -> (sharing::RefreshReport, mg_store::sync::SyncReport) {
+ let refresh = sharing::refresh(&d.vault, &d.client).await.unwrap();
+ let report = sync_once(&d.vault, &HttpRemote(&d.client)).await.unwrap();
+ assert!(report.vault_errors.is_empty(), "{:?}", report.vault_errors);
+ (refresh, report)
+}
+
+fn labels(d: &Device, vault: &str) -> Vec {
+ let mut out: Vec