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

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:
2026-10-08 06:44:39 -04:00
co-authored by Claude Opus 5.5
parent 802eab4ef9
commit 871878de41
7 changed files with 442 additions and 18 deletions
+5
View File
@@ -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
View File
@@ -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>
+2
View File
@@ -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:
+105
View File
@@ -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")
+3
View File
@@ -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",
+123
View File
@@ -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