install.sh installs from your own server, not just from the forge
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 1m15s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m5s
CI & Build / Build & push image (push) Successful in 55s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m15s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 2m57s
Desktop (Tauri) / Update manifest (push) Successful in 6s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 10s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 1m15s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m5s
CI & Build / Build & push image (push) Successful in 55s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m15s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 2m57s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Milestone 325 step 5 (Scribe #3253). curl -fsSL https://notes.example.com/install.sh | sh The server serves the installer at /install.sh with its own address written into it (installer.py). Settings → Public address when set, the request's own address otherwise. The substitution is one variable, given a value that has passed a strict shape check, and the script checks it again; an address that cannot pass makes the route refuse rather than serve a script pointed elsewhere. `public_url` joins the live settings cache so the route needs no database. From a server, the script: - resolves each Linux bundle from the public /api/client/<platform>, and builds the download URL from the platform id rather than reading it from the reply; - asks for a device token (from the terminal, since stdin is the script), or takes TS_TOKEN, and sends it from a file rather than the command line; - checks the sha256 the server published before anything installs; - revokes a prompted token once the download is done; - records `install-server` for the updater (step 6) instead of the channel. The forge path is unchanged, and stays the default for the copy the forge serves. The Account page's downloads card shows the one-line command whenever the server holds a Linux client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,11 @@ COPY alembic/ alembic/
|
|||||||
# rest (client_dist.py).
|
# rest (client_dist.py).
|
||||||
COPY client/ src/inkwell/client/
|
COPY client/ src/inkwell/client/
|
||||||
|
|
||||||
|
# The desktop installer, served at /install.sh with this server's own address
|
||||||
|
# written into it (installer.py). The SAME script the forge serves raw, copied rather
|
||||||
|
# than moved so the operator's documented forge URL keeps working.
|
||||||
|
COPY desktop/packaging/install.sh src/inkwell/install.sh
|
||||||
|
|
||||||
ENV PYTHONPATH=/app/src
|
ENV PYTHONPATH=/app/src
|
||||||
|
|
||||||
ARG BUILD_VERSION=dev
|
ARG BUILD_VERSION=dev
|
||||||
|
|||||||
+184
-18
@@ -2,8 +2,16 @@
|
|||||||
#
|
#
|
||||||
# Inkwell desktop — one-command Linux installer.
|
# Inkwell desktop — one-command Linux installer.
|
||||||
#
|
#
|
||||||
|
# curl -fsSL https://notes.example.com/install.sh | sh (from your server)
|
||||||
# curl -fsSL https://git.fabledsword.com/bvandeusen/inkwell/raw/branch/dev/desktop/packaging/install.sh | sh
|
# curl -fsSL https://git.fabledsword.com/bvandeusen/inkwell/raw/branch/dev/desktop/packaging/install.sh | sh
|
||||||
#
|
#
|
||||||
|
# FROM A SERVER, the script installs the build that server holds — the same one its
|
||||||
|
# Account page offers — and asks for a device token to download it with, because the
|
||||||
|
# bytes are not for anyone who can reach the port. The server writes its own address
|
||||||
|
# into the copy it serves (src/inkwell/installer.py), so nothing needs editing.
|
||||||
|
# Everything below about channels is the FORGE path; a server serves one build, and
|
||||||
|
# which channel that is was decided when its image was built.
|
||||||
|
#
|
||||||
# Two channels, the SAME two the app's own updater offers (src-tauri/src/update.rs):
|
# Two channels, the SAME two the app's own updater offers (src-tauri/src/update.rs):
|
||||||
# stable (default) — the rolling build from every merge to `main`.
|
# stable (default) — the rolling build from every merge to `main`.
|
||||||
# dev — the rolling build from every green push to `dev`.
|
# dev — the rolling build from every green push to `dev`.
|
||||||
@@ -46,25 +54,43 @@ usage() {
|
|||||||
cat <<'USAGE'
|
cat <<'USAGE'
|
||||||
Inkwell desktop installer.
|
Inkwell desktop installer.
|
||||||
|
|
||||||
install.sh [--channel stable|dev]
|
install.sh [--server URL] [--channel stable|dev]
|
||||||
|
|
||||||
--channel stable newest build from main (default)
|
--server URL install from this Inkwell server (set already when the
|
||||||
--channel dev rolling build from the latest green push to `dev`
|
script came from one)
|
||||||
|
--channel stable from the forge: newest build from main (default)
|
||||||
|
--channel dev from the forge: rolling build from the latest green push to `dev`
|
||||||
-h, --help this text
|
-h, --help this text
|
||||||
|
|
||||||
The channel can also come from TS_CHANNEL. Through a pipe, pass options after
|
The options can also come from TS_SERVER and TS_CHANNEL. Through a pipe, pass
|
||||||
`--`: curl -fsSL <url> | sh -s -- --channel dev
|
them after `--`: curl -fsSL <url> | sh -s -- --channel dev
|
||||||
|
|
||||||
|
From a server, the download needs a device token: make one in the web app under
|
||||||
|
Account → Linked devices and paste it when asked. A token typed at the prompt is
|
||||||
|
revoked again once the download is done. TS_TOKEN supplies one without a prompt,
|
||||||
|
and is left alone, since whoever set it is managing it.
|
||||||
USAGE
|
USAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# The address of the server that served this script. Written by that server
|
||||||
|
# (src/inkwell/installer.py rewrites exactly this line) and empty in the copy the
|
||||||
|
# forge serves, which is what keeps the forge path the default there.
|
||||||
|
TS_SERVER_DEFAULT=""
|
||||||
|
|
||||||
# --- channel ----------------------------------------------------------------
|
# --- channel ----------------------------------------------------------------
|
||||||
channel="${TS_CHANNEL:-stable}"
|
channel="${TS_CHANNEL:-stable}"
|
||||||
|
channel_asked="${TS_CHANNEL:-}"
|
||||||
|
server="${TS_SERVER:-$TS_SERVER_DEFAULT}"
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--channel)
|
--channel)
|
||||||
[ $# -ge 2 ] || die "--channel needs a value (stable or dev)."
|
[ $# -ge 2 ] || die "--channel needs a value (stable or dev)."
|
||||||
channel="$2"; shift 2 ;;
|
channel="$2"; channel_asked="$2"; shift 2 ;;
|
||||||
--channel=*) channel="${1#*=}"; shift ;;
|
--channel=*) channel="${1#*=}"; channel_asked="$channel"; shift ;;
|
||||||
|
--server)
|
||||||
|
[ $# -ge 2 ] || die "--server needs an address (https://…)."
|
||||||
|
server="$2"; shift 2 ;;
|
||||||
|
--server=*) server="${1#*=}"; shift ;;
|
||||||
-h | --help) usage; exit 0 ;;
|
-h | --help) usage; exit 0 ;;
|
||||||
*) die "unknown option: $1 (try --help)" ;;
|
*) die "unknown option: $1 (try --help)" ;;
|
||||||
esac
|
esac
|
||||||
@@ -76,6 +102,25 @@ esac
|
|||||||
|
|
||||||
have curl || die "curl is required."
|
have curl || die "curl is required."
|
||||||
|
|
||||||
|
# --- server address ---------------------------------------------------------
|
||||||
|
# Checked HERE as well as by the server that wrote it: this value goes into every
|
||||||
|
# URL below, and a script should not trust a string because of where it came from.
|
||||||
|
# The same shape the server enforces — a scheme, then only characters that cannot
|
||||||
|
# mean anything to a shell or a URL parser beyond what they say.
|
||||||
|
server="${server%/}"
|
||||||
|
if [ -n "$server" ]; then
|
||||||
|
case "$server" in
|
||||||
|
http://?* | https://?*) : ;;
|
||||||
|
*) die "the server address must start with http:// or https:// (got: $server)." ;;
|
||||||
|
esac
|
||||||
|
case "$server" in
|
||||||
|
*[!A-Za-z0-9:/._~-]*) die "the server address has characters an address cannot have (got: $server)." ;;
|
||||||
|
esac
|
||||||
|
if [ -n "$channel_asked" ]; then
|
||||||
|
say "Note: a server serves the one build it holds; --channel only applies to the forge."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# --- architecture gate ------------------------------------------------------
|
# --- architecture gate ------------------------------------------------------
|
||||||
# Only x86_64 is built today; arm64 will be added when the CI matrix grows. The
|
# Only x86_64 is built today; arm64 will be added when the CI matrix grows. The
|
||||||
# release-asset naming carries the arch, but since only one arch ships now we
|
# release-asset naming carries the arch, but since only one arch ships now we
|
||||||
@@ -86,7 +131,99 @@ case "$arch" in
|
|||||||
*) die "Inkwell ships x86_64 Linux builds only right now (this machine: $arch)." ;;
|
*) die "Inkwell ships x86_64 Linux builds only right now (this machine: $arch)." ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# --- resolve the release for this channel -----------------------------------
|
tmp="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$tmp"' EXIT INT TERM
|
||||||
|
|
||||||
|
# Every download goes through here, so the server path's token is sent on all of them
|
||||||
|
# and on nothing else. From a FILE, not the command line, where any user on the
|
||||||
|
# machine could read it out of `ps` while curl runs.
|
||||||
|
fetch() {
|
||||||
|
if [ -n "$server" ]; then
|
||||||
|
curl -fSL -H @"$tmp/auth" -o "$2" "$1"
|
||||||
|
else
|
||||||
|
curl -fSL -o "$2" "$1"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# The download against the digest its server published, BEFORE it reaches pacman,
|
||||||
|
# dpkg or a menu entry: a truncated file installs as something broken rather than
|
||||||
|
# failing. The forge path publishes no digest per bundle, so it passes an empty one
|
||||||
|
# and this checks nothing there; that is the forge path exactly as it always was.
|
||||||
|
verify() {
|
||||||
|
[ -n "$2" ] || return 0
|
||||||
|
if have sha256sum; then
|
||||||
|
got="$(sha256sum "$1" | cut -d' ' -f1)"
|
||||||
|
elif have shasum; then
|
||||||
|
got="$(shasum -a 256 "$1" | cut -d' ' -f1)"
|
||||||
|
else
|
||||||
|
die "can't check the download: neither sha256sum nor shasum is installed."
|
||||||
|
fi
|
||||||
|
[ "$got" = "$2" ] || die "the download doesn't match what the server published (sha256 $got, expected $2). Nothing was installed."
|
||||||
|
}
|
||||||
|
|
||||||
|
pkg_sha=""; deb_sha=""; appimage_sha=""
|
||||||
|
|
||||||
|
if [ -n "$server" ]; then
|
||||||
|
# --- resolve from a server ----------------------------------------------------
|
||||||
|
# `/api/client/<platform>` is public, so what the server holds is known before any
|
||||||
|
# token is asked for. The download URL is BUILT here from the platform id, never read
|
||||||
|
# from the reply — the same rule the apps follow (core/src/sync/client.rs): a reply
|
||||||
|
# that named some other host would otherwise be fetched, with the token attached.
|
||||||
|
say "Finding the Inkwell build $server holds…"
|
||||||
|
|
||||||
|
# One string field out of the flat JSON object the server returns. sed rather than
|
||||||
|
# a parser, for the same reason as the forge path's grep: no jq on most machines.
|
||||||
|
jfield() {
|
||||||
|
printf '%s' "$1" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -1
|
||||||
|
}
|
||||||
|
meta() { curl -fsS "$server/api/client/$1" 2>/dev/null || true; }
|
||||||
|
|
||||||
|
pkg_meta="$(meta linux-pacman)"
|
||||||
|
deb_meta="$(meta linux-deb)"
|
||||||
|
appimage_meta="$(meta linux-appimage)"
|
||||||
|
[ -n "$pkg_meta" ] || [ -n "$deb_meta" ] || [ -n "$appimage_meta" ] ||
|
||||||
|
die "$server has no Linux client to install (or isn't an Inkwell server — is the address right?)."
|
||||||
|
|
||||||
|
pkg_url=""; deb_url=""; appimage_url=""
|
||||||
|
if [ -n "$pkg_meta" ]; then
|
||||||
|
pkg_url="$server/api/client/linux-pacman/download"; pkg_sha="$(jfield "$pkg_meta" sha256)"
|
||||||
|
fi
|
||||||
|
if [ -n "$deb_meta" ]; then
|
||||||
|
deb_url="$server/api/client/linux-deb/download"; deb_sha="$(jfield "$deb_meta" sha256)"
|
||||||
|
fi
|
||||||
|
if [ -n "$appimage_meta" ]; then
|
||||||
|
appimage_url="$server/api/client/linux-appimage/download"; appimage_sha="$(jfield "$appimage_meta" sha256)"
|
||||||
|
fi
|
||||||
|
# One build, four bundles: any of them names the version.
|
||||||
|
version="$(jfield "$pkg_meta$deb_meta$appimage_meta" version)"
|
||||||
|
say "Installing ${version:-unknown} from $server"
|
||||||
|
|
||||||
|
# The token, from the environment or from the person at the keyboard. Read from the
|
||||||
|
# TERMINAL, not stdin: through `curl | sh`, stdin is this script.
|
||||||
|
token="${TS_TOKEN:-}"
|
||||||
|
prompted=""
|
||||||
|
if [ -z "$token" ]; then
|
||||||
|
{ [ -r /dev/tty ] && [ -w /dev/tty ]; } ||
|
||||||
|
die "the download needs a device token and there's no terminal to ask on; set TS_TOKEN."
|
||||||
|
printf 'A device token is needed to download from %s.\n' "$server" > /dev/tty
|
||||||
|
printf 'Make one in the web app under Account → Linked devices (it is revoked once the download is done).\n' > /dev/tty
|
||||||
|
printf 'Token: ' > /dev/tty
|
||||||
|
stty -echo < /dev/tty 2>/dev/null || true
|
||||||
|
IFS= read -r token < /dev/tty || true
|
||||||
|
stty echo < /dev/tty 2>/dev/null || true
|
||||||
|
printf '\n' > /dev/tty
|
||||||
|
prompted=1
|
||||||
|
fi
|
||||||
|
[ -n "$token" ] || die "no token given."
|
||||||
|
# The server's tokens are URL-safe base64. Anything else is a paste gone wrong, and a
|
||||||
|
# newline in it would be a second header.
|
||||||
|
case "$token" in
|
||||||
|
*[!A-Za-z0-9_-]*) die "that doesn't look like a device token (they're letters, digits, - and _)." ;;
|
||||||
|
esac
|
||||||
|
( umask 077; printf 'Authorization: Bearer %s\n' "$token" > "$tmp/auth" )
|
||||||
|
|
||||||
|
else
|
||||||
|
# --- resolve from the forge, for this channel --------------------------------
|
||||||
say "Finding the latest Inkwell build on the $channel channel…"
|
say "Finding the latest Inkwell build on the $channel channel…"
|
||||||
|
|
||||||
# ONE lookup, both channels. Each is a release whose tag never moves and whose assets
|
# ONE lookup, both channels. Each is a release whose tag never moves and whose assets
|
||||||
@@ -117,9 +254,19 @@ version="$(printf '%s' "$json" | grep -oE '"tag_name":"[^"]+"' | head -1 | sed -
|
|||||||
[ -n "$appimage_url" ] || [ -n "$deb_url" ] || [ -n "$pkg_url" ] ||
|
[ -n "$appimage_url" ] || [ -n "$deb_url" ] || [ -n "$pkg_url" ] ||
|
||||||
die "the $channel release (${version:-unknown}) has no installable Linux asset."
|
die "the $channel release (${version:-unknown}) has no installable Linux asset."
|
||||||
say "Installing ${version:-unknown} from the $channel channel"
|
say "Installing ${version:-unknown} from the $channel channel"
|
||||||
|
fi
|
||||||
|
|
||||||
tmp="$(mktemp -d)"
|
# A token typed at the prompt was made for this one install; once the bytes are here
|
||||||
trap 'rm -rf "$tmp"' EXIT INT TERM
|
# it has nothing left to do, so it goes. Best-effort: the install does not depend on
|
||||||
|
# it, and the person is told if it is still live.
|
||||||
|
revoke_token() {
|
||||||
|
[ -n "$server" ] && [ -n "$prompted" ] || return 0
|
||||||
|
if curl -fsS -o /dev/null -X DELETE -H @"$tmp/auth" "$server/api/auth/devices/self" 2>/dev/null; then
|
||||||
|
say "Revoked the download token."
|
||||||
|
else
|
||||||
|
say "Couldn't revoke the download token; remove it under Account → Linked devices."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
# Tell the app which channel it was installed from. The installer is the only thing
|
# Tell the app which channel it was installed from. The installer is the only thing
|
||||||
# that knows, and without this the app kept its own `stable` default and a dev install
|
# that knows, and without this the app kept its own `stable` default and a dev install
|
||||||
@@ -133,11 +280,21 @@ trap 'rm -rf "$tmp"' EXIT INT TERM
|
|||||||
#
|
#
|
||||||
# The directory is Tauri's app-data dir for identifier com.fabledsword.inkwell;
|
# The directory is Tauri's app-data dir for identifier com.fabledsword.inkwell;
|
||||||
# both sides hardcode it, so a change to the identifier has to change both.
|
# both sides hardcode it, so a change to the identifier has to change both.
|
||||||
|
#
|
||||||
|
# From a server, the sibling `install-server` is written instead: which server the app
|
||||||
|
# came from, for the updater to follow (milestone 325, step 6). The channel marker is
|
||||||
|
# left alone on that path, because a server's channel is whatever its image was built
|
||||||
|
# with and nothing here can know it.
|
||||||
record_channel() {
|
record_channel() {
|
||||||
marker_dir="${XDG_DATA_HOME:-$HOME/.local/share}/com.fabledsword.inkwell"
|
marker_dir="${XDG_DATA_HOME:-$HOME/.local/share}/com.fabledsword.inkwell"
|
||||||
# Best-effort: a failure here costs the channel setting, not the install, and a
|
# Best-effort: a failure here costs the channel setting, not the install, and a
|
||||||
# native install run as root would only be writing into root's home anyway.
|
# native install run as root would only be writing into root's home anyway.
|
||||||
mkdir -p "$marker_dir" 2>/dev/null && printf '%s\n' "$channel" > "$marker_dir/install-channel" 2>/dev/null || true
|
mkdir -p "$marker_dir" 2>/dev/null || return 0
|
||||||
|
if [ -n "$server" ]; then
|
||||||
|
printf '%s\n' "$server" > "$marker_dir/install-server" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
printf '%s\n' "$channel" > "$marker_dir/install-channel" 2>/dev/null || true
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Both native paths install system-wide, so they need root. Resolved once here
|
# Both native paths install system-wide, so they need root. Resolved once here
|
||||||
@@ -155,7 +312,9 @@ need_root() {
|
|||||||
# than letting someone discover it from a greyed-out button.
|
# than letting someone discover it from a greyed-out button.
|
||||||
native_update_note() {
|
native_update_note() {
|
||||||
printf ' A package-manager install can'\''t update itself in-app.\n'
|
printf ' A package-manager install can'\''t update itself in-app.\n'
|
||||||
if [ "$channel" = "dev" ]; then
|
if [ -n "$server" ]; then
|
||||||
|
printf ' Re-run this script from %s to move to the build it holds.\n' "$server"
|
||||||
|
elif [ "$channel" = "dev" ]; then
|
||||||
printf ' Re-run this script with --channel dev to move to a newer dev build.\n'
|
printf ' Re-run this script with --channel dev to move to a newer dev build.\n'
|
||||||
else
|
else
|
||||||
printf ' Re-run this script to move to a newer release.\n'
|
printf ' Re-run this script to move to a newer release.\n'
|
||||||
@@ -171,8 +330,11 @@ if have pacman && [ -n "$pkg_url" ]; then
|
|||||||
say "Arch-family system detected — installing the native pacman package"
|
say "Arch-family system detected — installing the native pacman package"
|
||||||
# Keep the published filename: pacman -U expects a *.pkg.tar.* name and refuses
|
# Keep the published filename: pacman -U expects a *.pkg.tar.* name and refuses
|
||||||
# a file that doesn't look like a package, whatever its actual contents.
|
# a file that doesn't look like a package, whatever its actual contents.
|
||||||
pkg_file="$tmp/$(basename "$pkg_url")"
|
# From a server the URL ends in `/download`, so the name comes from the platform.
|
||||||
curl -fSL -o "$pkg_file" "$pkg_url"
|
if [ -n "$server" ]; then pkg_file="$tmp/inkwell.pkg.tar.zst"; else pkg_file="$tmp/$(basename "$pkg_url")"; fi
|
||||||
|
fetch "$pkg_url" "$pkg_file"
|
||||||
|
verify "$pkg_file" "$pkg_sha"
|
||||||
|
revoke_token
|
||||||
need_root
|
need_root
|
||||||
$sudo pacman -U --noconfirm "$pkg_file"
|
$sudo pacman -U --noconfirm "$pkg_file"
|
||||||
record_channel
|
record_channel
|
||||||
@@ -184,7 +346,9 @@ fi
|
|||||||
# --- native .deb path (Debian/Ubuntu) ---------------------------------------
|
# --- native .deb path (Debian/Ubuntu) ---------------------------------------
|
||||||
if have dpkg && have apt-get && [ -n "$deb_url" ]; then
|
if have dpkg && have apt-get && [ -n "$deb_url" ]; then
|
||||||
say "Debian-family system detected — installing the native .deb"
|
say "Debian-family system detected — installing the native .deb"
|
||||||
curl -fSL -o "$tmp/inkwell.deb" "$deb_url"
|
fetch "$deb_url" "$tmp/inkwell.deb"
|
||||||
|
verify "$tmp/inkwell.deb" "$deb_sha"
|
||||||
|
revoke_token
|
||||||
need_root
|
need_root
|
||||||
# apt-get resolves the .deb's dependencies (webkit2gtk etc.). dpkg is the
|
# apt-get resolves the .deb's dependencies (webkit2gtk etc.). dpkg is the
|
||||||
# fallback if this apt is too old for local-file installs — it leaves the deps
|
# fallback if this apt is too old for local-file installs — it leaves the deps
|
||||||
@@ -207,8 +371,10 @@ say "Installing the de-bundled AppImage (user-local, no sudo)"
|
|||||||
apps_dir="$HOME/Applications"
|
apps_dir="$HOME/Applications"
|
||||||
dest="$apps_dir/Inkwell.AppImage"
|
dest="$apps_dir/Inkwell.AppImage"
|
||||||
mkdir -p "$apps_dir"
|
mkdir -p "$apps_dir"
|
||||||
say "Downloading $(basename "$appimage_url")…"
|
say "Downloading the AppImage…"
|
||||||
curl -fSL -o "$tmp/Inkwell.AppImage" "$appimage_url"
|
fetch "$appimage_url" "$tmp/Inkwell.AppImage"
|
||||||
|
verify "$tmp/Inkwell.AppImage" "$appimage_sha"
|
||||||
|
revoke_token
|
||||||
chmod +x "$tmp/Inkwell.AppImage"
|
chmod +x "$tmp/Inkwell.AppImage"
|
||||||
mv -f "$tmp/Inkwell.AppImage" "$dest"
|
mv -f "$tmp/Inkwell.AppImage" "$dest"
|
||||||
|
|
||||||
@@ -257,7 +423,7 @@ say "Installed to $dest"
|
|||||||
printf ' Launch it from your application menu, or run \033[1minkwell\033[0m'
|
printf ' Launch it from your application menu, or run \033[1minkwell\033[0m'
|
||||||
printf ' (if ~/.local/bin is on your PATH).\n'
|
printf ' (if ~/.local/bin is on your PATH).\n'
|
||||||
# This is the one path where the app can update itself, so say what it will follow.
|
# This is the one path where the app can update itself, so say what it will follow.
|
||||||
if [ "$channel" = "dev" ]; then
|
if [ -z "$server" ] && [ "$channel" = "dev" ]; then
|
||||||
printf ' In-app updates will follow the \033[1mdev\033[0m channel.'
|
printf ' In-app updates will follow the \033[1mdev\033[0m channel.'
|
||||||
printf ' Change it in Sync → App updates.\n'
|
printf ' Change it in Sync → App updates.\n'
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -101,6 +101,13 @@ const missingPlatform = computed(() =>
|
|||||||
!lead.value.length && (family === "mac" || family === "ios") ? FAMILY_TITLE[family] : "",
|
!lead.value.length && (family === "mac" || family === "ios") ? FAMILY_TITLE[family] : "",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The one-command Linux install (milestone 325 step 5), shown wherever the server
|
||||||
|
// holds a Linux client to install. Against the address this page was reached on,
|
||||||
|
// which is the one the person can evidently reach; the script the server sends
|
||||||
|
// back carries Settings → Public address when that is set.
|
||||||
|
const hasLinux = computed(() => Object.keys(config.clients).some((id) => id.startsWith("linux-")));
|
||||||
|
const installCommand = `curl -fsSL ${window.location.origin}/install.sh | sh`;
|
||||||
|
|
||||||
// One decimal below 10 MB, none above: these sit in one list where a 2.7 MB
|
// One decimal below 10 MB, none above: these sit in one list where a 2.7 MB
|
||||||
// package and a 95 MB AppImage are compared, and "3 MB" next to "95 MB" loses the
|
// package and a 95 MB AppImage are compared, and "3 MB" next to "95 MB" loses the
|
||||||
// only distinction that matters at the small end.
|
// only distinction that matters at the small end.
|
||||||
@@ -163,5 +170,18 @@ function readableSize(bytes: number): string {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasLinux" class="mt-4">
|
||||||
|
<p class="text-xs text-neutral-400">
|
||||||
|
Or on Linux, from a terminal. It picks the right package for the system and asks
|
||||||
|
for a token from Linked devices to download it with.
|
||||||
|
</p>
|
||||||
|
<!-- select-all: one click takes the whole command, which is the only thing
|
||||||
|
anyone does with it. -->
|
||||||
|
<code
|
||||||
|
class="mt-1 block select-all break-all rounded bg-neutral-100 px-2 py-1 text-xs text-neutral-700 dark:bg-neutral-900 dark:text-neutral-200"
|
||||||
|
>{{ installCommand }}</code
|
||||||
|
>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from quart.sessions import SecureCookieSessionInterface
|
|||||||
from .accounts_api import bp as accounts_bp
|
from .accounts_api import bp as accounts_bp
|
||||||
from .auth import bp as auth_bp
|
from .auth import bp as auth_bp
|
||||||
from .client_dist import advertisement as client_advertisement, bp as client_bp
|
from .client_dist import advertisement as client_advertisement, bp as client_bp
|
||||||
|
from .installer import bp as installer_bp
|
||||||
from .groups_api import bp as groups_bp
|
from .groups_api import bp as groups_bp
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .db import session_scope
|
from .db import session_scope
|
||||||
@@ -100,6 +101,7 @@ def create_app() -> Quart:
|
|||||||
app.register_blueprint(shares_bp)
|
app.register_blueprint(shares_bp)
|
||||||
app.register_blueprint(sync_bp)
|
app.register_blueprint(sync_bp)
|
||||||
app.register_blueprint(client_bp)
|
app.register_blueprint(client_bp)
|
||||||
|
app.register_blueprint(installer_bp)
|
||||||
|
|
||||||
@app.before_serving
|
@app.before_serving
|
||||||
async def _bootstrap() -> None:
|
async def _bootstrap() -> None:
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"""`GET /install.sh` — the desktop installer, pointed at the server that served it.
|
||||||
|
|
||||||
|
curl -fsSL https://notes.example.com/install.sh | sh
|
||||||
|
|
||||||
|
## Why the server templates it
|
||||||
|
|
||||||
|
`curl | sh` gives a script no way to learn where it came from: no argv, no referrer,
|
||||||
|
nothing. So the server writes its own address into the copy it hands out, and the
|
||||||
|
script installs from there without being told. Anything else means a self-hoster
|
||||||
|
editing a URL by hand, which is the friction `client_dist.py` exists to remove.
|
||||||
|
|
||||||
|
## Which way it fails
|
||||||
|
|
||||||
|
A server that can put arbitrary text into a script a person pipes to `sh` is the
|
||||||
|
whole attack. So the substitution is ONE variable, `TS_SERVER_DEFAULT`, given a value
|
||||||
|
that has already passed [SAFE_ADDRESS] — no quote, no space, no `$`, nothing a shell
|
||||||
|
reads as anything but characters — and the script checks the shape again before it
|
||||||
|
uses it. If the address cannot pass, the route refuses rather than serving a script
|
||||||
|
that would install from somewhere else.
|
||||||
|
|
||||||
|
## Where the script comes from
|
||||||
|
|
||||||
|
ONE script. The forge serves `desktop/packaging/install.sh` raw, and that is how the
|
||||||
|
operator installs. The image carries a copy beside this module (the Dockerfile copies
|
||||||
|
it in), and a source checkout reads the original. Same bytes either way; this module
|
||||||
|
changes exactly one line of them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from quart import Blueprint, Response, request
|
||||||
|
|
||||||
|
from .proxy import is_https
|
||||||
|
from .responses import json_error
|
||||||
|
from .settings import live
|
||||||
|
|
||||||
|
bp = Blueprint("installer", __name__)
|
||||||
|
|
||||||
|
# The copy the image carries (Dockerfile), and the original in a source checkout.
|
||||||
|
BAKED_SCRIPT = Path(__file__).resolve().parent / "install.sh"
|
||||||
|
SOURCE_SCRIPT = Path(__file__).resolve().parents[2] / "desktop" / "packaging" / "install.sh"
|
||||||
|
|
||||||
|
# The one line the server rewrites. The script carries it exactly like this, and
|
||||||
|
# `render` refuses a script that does not, rather than serving one with no address in.
|
||||||
|
PLACEHOLDER = 'TS_SERVER_DEFAULT=""'
|
||||||
|
|
||||||
|
# What a server address may look like before it goes into a shell script: a scheme,
|
||||||
|
# a host name or IPv4 address, an optional port and an optional path. Deliberately
|
||||||
|
# narrower than a URL. Every character it admits is inert inside single quotes, and
|
||||||
|
# an address it turns away (an IPv6 literal, a path with `%` in it) is answered by
|
||||||
|
# setting Settings → Public address, not by widening this.
|
||||||
|
SAFE_ADDRESS = re.compile(r"https?://[A-Za-z0-9.-]+(:[0-9]{1,5})?(/[A-Za-z0-9._~/-]*)?")
|
||||||
|
|
||||||
|
|
||||||
|
def script_text() -> str | None:
|
||||||
|
"""The installer as published, or None when this server has no copy of it."""
|
||||||
|
for path in (BAKED_SCRIPT, SOURCE_SCRIPT):
|
||||||
|
try:
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def server_address(public_url: str, scheme: str, host: str) -> str | None:
|
||||||
|
"""Where the installer should fetch from, or None if no safe answer exists.
|
||||||
|
|
||||||
|
Settings → Public address when it is set: it is the address the operator says
|
||||||
|
people use, and behind a reverse proxy the request's own Host may be an internal
|
||||||
|
one. Otherwise the address this request arrived on, which is the one the person
|
||||||
|
running `curl` just reached.
|
||||||
|
"""
|
||||||
|
address = (public_url or f"{scheme}://{host}").rstrip("/")
|
||||||
|
return address if SAFE_ADDRESS.fullmatch(address) else None
|
||||||
|
|
||||||
|
|
||||||
|
def render(script: str, address: str) -> str | None:
|
||||||
|
"""`script` with `address` as its default server, or None if it cannot be done safely."""
|
||||||
|
if not SAFE_ADDRESS.fullmatch(address) or script.count(PLACEHOLDER) != 1:
|
||||||
|
return None
|
||||||
|
return script.replace(PLACEHOLDER, f"TS_SERVER_DEFAULT='{address}'")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/install.sh")
|
||||||
|
async def install_script():
|
||||||
|
"""Public, like the client metadata: the script holds nothing secret, and the
|
||||||
|
bytes it goes on to fetch are behind a device token."""
|
||||||
|
script = script_text()
|
||||||
|
if script is None:
|
||||||
|
return json_error("this server has no installer", 404)
|
||||||
|
address = server_address(live("public_url"), "https" if is_https() else "http", request.host)
|
||||||
|
if address is None:
|
||||||
|
return json_error(
|
||||||
|
"this server cannot state its own address safely; set Settings → Public address",
|
||||||
|
500,
|
||||||
|
)
|
||||||
|
body = render(script, address)
|
||||||
|
if body is None:
|
||||||
|
return json_error("this server's installer is not the shape it expects", 500)
|
||||||
|
# Plain text so a browser shows it rather than downloading it: "read it before you
|
||||||
|
# pipe it" is the script's own advice.
|
||||||
|
return Response(body, mimetype="text/plain")
|
||||||
@@ -316,6 +316,9 @@ async def get_admin_settings(db) -> list[dict]:
|
|||||||
# and again whenever an admin saves. Same live-update contract `session_ttl_days`
|
# and again whenever an admin saves. Same live-update contract `session_ttl_days`
|
||||||
# already has in settings_api.py.
|
# already has in settings_api.py.
|
||||||
_LIVE_KEYS = (
|
_LIVE_KEYS = (
|
||||||
|
# Read by GET /install.sh, which writes it into the installer it serves
|
||||||
|
# (installer.py) and must not need a database to answer.
|
||||||
|
"public_url",
|
||||||
"trusted_proxy_hops",
|
"trusted_proxy_hops",
|
||||||
"signin_limit_per_account",
|
"signin_limit_per_account",
|
||||||
"signin_limit_per_address",
|
"signin_limit_per_address",
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from inkwell import installer, settings
|
||||||
|
from inkwell.app import create_app
|
||||||
|
from inkwell.installer import PLACEHOLDER, SOURCE_SCRIPT, render, server_address
|
||||||
|
|
||||||
|
# DB-free, like test_client_dist: the route reads `public_url` from the live cache
|
||||||
|
# rather than from the database, which is what makes it testable here.
|
||||||
|
|
||||||
|
SCRIPT = f"#!/bin/sh\nset -eu\n{PLACEHOLDER}\nserver=\"${{TS_SERVER:-$TS_SERVER_DEFAULT}}\"\n"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _defaults():
|
||||||
|
settings.reset_live()
|
||||||
|
yield
|
||||||
|
settings.reset_live()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app():
|
||||||
|
return create_app()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_published_script_carries_the_line_the_server_rewrites_exactly_once():
|
||||||
|
"""The forge-served script and the server-served one are the same file; this is
|
||||||
|
the one line that differs. Edit it and the server stops serving rather than
|
||||||
|
serving a script with no address in it — this test is what says so first."""
|
||||||
|
assert SOURCE_SCRIPT.read_text(encoding="utf-8").count(PLACEHOLDER) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_writes_the_address_into_the_one_variable():
|
||||||
|
body = render(SCRIPT, "https://notes.example.com")
|
||||||
|
assert "TS_SERVER_DEFAULT='https://notes.example.com'" in body
|
||||||
|
assert PLACEHOLDER not in body
|
||||||
|
# Nothing else moved.
|
||||||
|
assert body.replace("TS_SERVER_DEFAULT='https://notes.example.com'", PLACEHOLDER) == SCRIPT
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"address",
|
||||||
|
[
|
||||||
|
"https://notes.example.com/sub/path",
|
||||||
|
"http://192.168.1.20:5000",
|
||||||
|
"https://notes.example.com:8443",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_an_ordinary_address_is_accepted(address):
|
||||||
|
assert render(SCRIPT, address) is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"address",
|
||||||
|
[
|
||||||
|
# Each is a way out of the single quotes, or a second command.
|
||||||
|
"https://x.example.com'; rm -rf ~; '",
|
||||||
|
"https://x.example.com/$(id)",
|
||||||
|
"https://x.example.com/`id`",
|
||||||
|
"https://x.example.com\nrm -rf ~",
|
||||||
|
"https://x.example.com/a b",
|
||||||
|
"https://x.example.com/;id",
|
||||||
|
# Not an address at all.
|
||||||
|
"ftp://x.example.com",
|
||||||
|
"x.example.com",
|
||||||
|
"",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_an_address_that_could_mean_something_to_a_shell_is_refused(address):
|
||||||
|
assert render(SCRIPT, address) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("script", ["#!/bin/sh\n", SCRIPT + PLACEHOLDER + "\n"])
|
||||||
|
def test_a_script_without_exactly_one_placeholder_is_not_served(script):
|
||||||
|
assert render(script, "https://notes.example.com") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_public_address_setting_beats_the_request_host():
|
||||||
|
"""Behind a reverse proxy the Host this request arrived with may be an internal
|
||||||
|
one; Settings → Public address is what the operator says people use."""
|
||||||
|
assert server_address("https://notes.example.com", "http", "inkwell:5000") == "https://notes.example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_the_setting_the_request_address_is_used():
|
||||||
|
assert server_address("", "https", "notes.example.com") == "https://notes.example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_trailing_slash_is_dropped():
|
||||||
|
assert server_address("https://notes.example.com/", "http", "ignored") == "https://notes.example.com"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_host_header_that_is_not_an_address_yields_none():
|
||||||
|
assert server_address("", "http", "evil.example.com'$(id)") is None
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_route_serves_the_script_pointed_at_this_server(app):
|
||||||
|
resp = await app.test_client().get("/install.sh", headers={"Host": "notes.example.com"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.mimetype == "text/plain"
|
||||||
|
body = await resp.get_data(as_text=True)
|
||||||
|
assert "TS_SERVER_DEFAULT='http://notes.example.com'" in body
|
||||||
|
assert body.startswith("#!/bin/sh")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_route_uses_the_public_address_when_one_is_set(app, monkeypatch):
|
||||||
|
monkeypatch.setitem(settings._live, "public_url", "https://notes.example.com")
|
||||||
|
resp = await app.test_client().get("/install.sh", headers={"Host": "inkwell:5000"})
|
||||||
|
assert "TS_SERVER_DEFAULT='https://notes.example.com'" in await resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_route_refuses_rather_than_serve_an_unsafe_address(app, monkeypatch):
|
||||||
|
# A Public address saved before it was validated this strictly: Settings only
|
||||||
|
# checks the scheme, so this is reachable.
|
||||||
|
monkeypatch.setitem(settings._live, "public_url", "https://notes.example.com/'$(id)'")
|
||||||
|
resp = await app.test_client().get("/install.sh", headers={"Host": "notes.example.com"})
|
||||||
|
assert resp.status_code == 500
|
||||||
|
assert "TS_SERVER_DEFAULT" not in await resp.get_data(as_text=True)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_server_with_no_copy_of_the_script_404s(app, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(installer, "BAKED_SCRIPT", tmp_path / "absent")
|
||||||
|
monkeypatch.setattr(installer, "SOURCE_SCRIPT", tmp_path / "also-absent")
|
||||||
|
resp = await app.test_client().get("/install.sh")
|
||||||
|
assert resp.status_code == 404
|
||||||
Reference in New Issue
Block a user