AGPL-3.0-only, a source link in the app, and everything on dev since #5 #6
@@ -43,6 +43,11 @@ COPY alembic/ alembic/
|
||||
# rest (client_dist.py).
|
||||
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
|
||||
|
||||
ARG BUILD_VERSION=dev
|
||||
|
||||
+184
-18
@@ -2,8 +2,16 @@
|
||||
#
|
||||
# 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
|
||||
#
|
||||
# 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):
|
||||
# stable (default) — the rolling build from every merge to `main`.
|
||||
# dev — the rolling build from every green push to `dev`.
|
||||
@@ -46,25 +54,43 @@ usage() {
|
||||
cat <<'USAGE'
|
||||
Inkwell desktop installer.
|
||||
|
||||
install.sh [--channel stable|dev]
|
||||
install.sh [--server URL] [--channel stable|dev]
|
||||
|
||||
--channel stable newest build from main (default)
|
||||
--channel dev rolling build from the latest green push to `dev`
|
||||
--server URL install from this Inkwell server (set already when the
|
||||
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
|
||||
|
||||
The channel can also come from TS_CHANNEL. Through a pipe, pass options after
|
||||
`--`: curl -fsSL <url> | sh -s -- --channel dev
|
||||
The options can also come from TS_SERVER and TS_CHANNEL. Through a pipe, pass
|
||||
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
|
||||
}
|
||||
|
||||
# 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="${TS_CHANNEL:-stable}"
|
||||
channel_asked="${TS_CHANNEL:-}"
|
||||
server="${TS_SERVER:-$TS_SERVER_DEFAULT}"
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--channel)
|
||||
[ $# -ge 2 ] || die "--channel needs a value (stable or dev)."
|
||||
channel="$2"; shift 2 ;;
|
||||
--channel=*) channel="${1#*=}"; shift ;;
|
||||
channel="$2"; channel_asked="$2"; shift 2 ;;
|
||||
--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 ;;
|
||||
*) die "unknown option: $1 (try --help)" ;;
|
||||
esac
|
||||
@@ -76,6 +102,25 @@ esac
|
||||
|
||||
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 ------------------------------------------------------
|
||||
# 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
|
||||
@@ -86,7 +131,99 @@ case "$arch" in
|
||||
*) die "Inkwell ships x86_64 Linux builds only right now (this machine: $arch)." ;;
|
||||
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…"
|
||||
|
||||
# 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" ] ||
|
||||
die "the $channel release (${version:-unknown}) has no installable Linux asset."
|
||||
say "Installing ${version:-unknown} from the $channel channel"
|
||||
fi
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT INT TERM
|
||||
# A token typed at the prompt was made for this one install; once the bytes are here
|
||||
# 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
|
||||
# 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;
|
||||
# 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() {
|
||||
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
|
||||
# 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
|
||||
@@ -155,7 +312,9 @@ need_root() {
|
||||
# than letting someone discover it from a greyed-out button.
|
||||
native_update_note() {
|
||||
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'
|
||||
else
|
||||
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"
|
||||
# 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.
|
||||
pkg_file="$tmp/$(basename "$pkg_url")"
|
||||
curl -fSL -o "$pkg_file" "$pkg_url"
|
||||
# From a server the URL ends in `/download`, so the name comes from the platform.
|
||||
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
|
||||
$sudo pacman -U --noconfirm "$pkg_file"
|
||||
record_channel
|
||||
@@ -184,7 +346,9 @@ fi
|
||||
# --- native .deb path (Debian/Ubuntu) ---------------------------------------
|
||||
if have dpkg && have apt-get && [ -n "$deb_url" ]; then
|
||||
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
|
||||
# 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
|
||||
@@ -207,8 +371,10 @@ say "Installing the de-bundled AppImage (user-local, no sudo)"
|
||||
apps_dir="$HOME/Applications"
|
||||
dest="$apps_dir/Inkwell.AppImage"
|
||||
mkdir -p "$apps_dir"
|
||||
say "Downloading $(basename "$appimage_url")…"
|
||||
curl -fSL -o "$tmp/Inkwell.AppImage" "$appimage_url"
|
||||
say "Downloading the AppImage…"
|
||||
fetch "$appimage_url" "$tmp/Inkwell.AppImage"
|
||||
verify "$tmp/Inkwell.AppImage" "$appimage_sha"
|
||||
revoke_token
|
||||
chmod +x "$tmp/Inkwell.AppImage"
|
||||
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 ' (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.
|
||||
if [ "$channel" = "dev" ]; then
|
||||
if [ -z "$server" ] && [ "$channel" = "dev" ]; then
|
||||
printf ' In-app updates will follow the \033[1mdev\033[0m channel.'
|
||||
printf ' Change it in Sync → App updates.\n'
|
||||
fi
|
||||
|
||||
@@ -101,6 +101,13 @@ const missingPlatform = computed(() =>
|
||||
!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
|
||||
// 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.
|
||||
@@ -163,5 +170,18 @@ function readableSize(bytes: number): string {
|
||||
</li>
|
||||
</ul>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -14,6 +14,7 @@ from quart.sessions import SecureCookieSessionInterface
|
||||
from .accounts_api import bp as accounts_bp
|
||||
from .auth import bp as auth_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 .config import Config
|
||||
from .db import session_scope
|
||||
@@ -100,6 +101,7 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(shares_bp)
|
||||
app.register_blueprint(sync_bp)
|
||||
app.register_blueprint(client_bp)
|
||||
app.register_blueprint(installer_bp)
|
||||
|
||||
@app.before_serving
|
||||
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`
|
||||
# already has in settings_api.py.
|
||||
_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",
|
||||
"signin_limit_per_account",
|
||||
"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