From 750b98dd18eb389a1e0e173b2227f3cbc8ec7469 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 13:02:58 +0000 Subject: [PATCH] Installer: signed releases, pinned app releases with SHA-256 checks, and the security page - install.sh installs a pinned release of each app and checks every file it downloads against the SHA-256 written in it, stopping on a mismatch. scripts/pin-apps.sh writes the list after an app's release; CI checks it on every change and every Monday. - release.yml publishes install.sh and the selfhostit command as a release with SHA-256 sums and a signed attestation; the docs install from the newest release. - security.md: sign-in, sessions and logs (bcrypt, tokens, what is logged and for how long), the security scans and GitHub protections, what needs sudo and why, how to verify the installer, and the reporting route with response times. - Updates and backups: how to make an encrypted backup with age. --- .github/workflows/ci.yml | 16 +++-- .github/workflows/release.yml | 51 ++++++++++++++ README.md | 28 +++++--- install.sh | 95 +++++++++++++++++++++---- linux.md | 4 +- other-nas.md | 2 +- scripts/pin-apps.sh | 70 ++++++++++++++++++ security.md | 129 +++++++++++++++++++++++++--------- synology.md | 2 +- updates-and-backups.md | 15 ++-- 10 files changed, 346 insertions(+), 66 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100755 scripts/pin-apps.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f754b2..57aae87 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,9 @@ on: push: branches: [main] pull_request: + # weekly, to notice a pinned file that changed on GitHub or an app with a newer release + schedule: + - cron: '7 6 * * 1' permissions: contents: read @@ -12,7 +15,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 - - run: shellcheck -s sh install.sh - - run: sh -n install.sh - - run: shellcheck -s sh selfhostit - - run: sh -n selfhostit + - run: shellcheck -s sh install.sh selfhostit scripts/pin-apps.sh + - run: sh -n install.sh && sh -n selfhostit && sh -n scripts/pin-apps.sh + + # Every file install.sh downloads must still have the SHA-256 it pins. + pins: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - run: scripts/pin-apps.sh --check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..bda077f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,51 @@ +name: release + +# Publishes a release of the installer: install.sh and the selfhostit command as +# downloads, their SHA-256 sums, and a signed attestation of the workflow and commit +# they came from. People install from +# https://github.com/selfhostit/docs/releases/latest/download/install.sh. +# +# gh workflow run release.yml --repo selfhostit/docs -f version=v1.2.0 +# +# Releases are immutable, so the files go in when the release is created. +on: + workflow_dispatch: + inputs: + version: + description: Version tag, like v1.2.0 + required: true + +permissions: + contents: read + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write # to create the release and its tag + id-token: write # to sign the attestation + attestations: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - name: Check the version + run: | + echo "$VERSION" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' || { echo "not a version like v1.2.0: $VERSION"; exit 1; } + env: + VERSION: ${{ inputs.version }} + - name: Check the pinned app files + run: scripts/pin-apps.sh --check + - name: SHA-256 sums + run: sha256sum install.sh selfhostit > SHA256SUMS && cat SHA256SUMS + - name: Attest the files + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-path: | + install.sh + selfhostit + - name: Publish the release + run: | + gh release create "$VERSION" install.sh selfhostit SHA256SUMS \ + --target "$GITHUB_SHA" --title "Installer $VERSION" --generate-notes + env: + VERSION: ${{ inputs.version }} + GH_TOKEN: ${{ github.token }} diff --git a/README.md b/README.md index dd87ee4..afb2cf3 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ another program already has it, the app gets the next free port, and keeps that On a Raspberry Pi, a Linux server or a NAS you can reach over SSH: ```sh -curl -fsSL https://raw.githubusercontent.com/selfhostit/docs/main/install.sh | sh +curl -fsSL https://github.com/selfhostit/docs/releases/latest/download/install.sh | sh ``` It installs Docker if it is missing (on Linux), asks which apps you want and where to keep @@ -31,14 +31,19 @@ It can also set up [Tailscale](phones.md#tailscale-recommended), so your phones over HTTPS, which they need to install an app and get notifications. You only open a link to sign in; there are no keys or settings to copy. Then [put the apps on your phones](phones.md). -Prefer to read a script before you run it? Download it, read it, then run it: +Prefer to read and check a script before you run it? Download it, verify it, read it, then +run it: ```sh -curl -fsSLO https://raw.githubusercontent.com/selfhostit/docs/main/install.sh +curl -fsSLO https://github.com/selfhostit/docs/releases/latest/download/install.sh +gh attestation verify install.sh --repo selfhostit/docs less install.sh sh install.sh ``` +The installer is a signed release, and it installs a pinned release of each app, checking +every file it downloads against its SHA-256. + [Security and privacy](security.md) says step by step what it does, what the apps protect and what you are trusting. @@ -46,13 +51,16 @@ and what you are trusting. The apps keep their data on your machine, in one folder each, with no selfhostit cloud and no analytics. They listen on `127.0.0.1` only and are reached through Tailscale or your own -reverse proxy, never a forwarded port. Containers drop every Linux capability, a new install -asks for a one-time setup code, sign-ins are rate limited and the web apps send a Content -Security Policy. CI runs the tests on every change, with Actions pinned to commit hashes. -Updates follow versioned releases, and each image has a signed attestation of the commit that -built it. Not done yet: an independent audit. -Details, with links to the code: [Security and privacy](security.md). Report a problem to -kalfasyan@pm.me ([policy](https://github.com/selfhostit/.github/blob/main/SECURITY.md)). +reverse proxy, never a forwarded port. Containers run as a normal user with no Linux +capabilities and a read-only filesystem, a new install asks for a one-time setup code, +passwords are stored as bcrypt hashes, sign-ins are rate limited and the web apps send a +Content Security Policy. Every change, and every week, is scanned with CodeQL, govulncheck, +Grype and gitleaks. Updates follow versioned releases, each image has a signed attestation +of the commit that built it, and the installer pins each app's release with SHA-256 checks. +Not done yet: an independent audit. +Details, with links to the code: [Security and privacy](security.md). Report a problem +privately through the app's Security tab on GitHub or to kalfasyan@pm.me +([policy](https://github.com/selfhostit/.github/blob/main/SECURITY.md)). ## Guides diff --git a/install.sh b/install.sh index 440a1f3..0e75726 100644 --- a/install.sh +++ b/install.sh @@ -2,10 +2,14 @@ # Installs or updates selfhostit apps on this machine with Docker: a Raspberry Pi, # a Linux server or VPS, or a NAS with SSH and Docker (Synology, QNAP, Unraid, ...). # -# curl -fsSL https://raw.githubusercontent.com/selfhostit/docs/main/install.sh | sh +# curl -fsSL https://github.com/selfhostit/docs/releases/latest/download/install.sh | sh # -# To read it before running it, download it first (curl -fsSLO ), -# then run `sh install.sh`. security.md says what it does, step by step. +# That is the newest release of this installer. To read and check it before running it, +# download it first (curl -fsSLO ), then run `sh install.sh`; security.md +# says what it does step by step and how to verify it. +# +# It installs a fixed release of each app, listed below with the SHA-256 of every file +# it downloads for it, and stops if a download doesn't match. # # It asks which apps you want, gives each one its own folder and port (the next # free one when another program has the usual port), and runs @@ -56,6 +60,71 @@ fetch() { if command -v curl >/dev/null; then curl -fsSL "$1"; else wget -qO- "$1"; fi } +# --- Pinned releases ------------------------------------------------------------ + +# The release of each app this installer sets up, and the SHA-256 of every file it +# downloads from it: app, tag, file, SHA-256. scripts/pin-apps.sh writes this list +# after a release, and CI checks it against GitHub. +pins() { + cat <<-'EOF' + # pins start + splitwiz v1.0.0 scripts/install.sh c7ce9f5027d5e9f85473734edc4f5ec50b4ad611f12ac7836efd8c51e4a18f0f + splitwiz v1.0.0 docker-compose.yml 84d675d0e3f26e823014d111f1454d1dab9a913a126114e27e7e28bc6429d642 + should-we v2.2.0 docker-compose.yml 57a54827444aefe86c60a7adcf4980cd3bfc57c844bcef946e12265e43d2b27b + flowy v1.0.0 deploy/install.sh 023ce978e85a53569b5a1851bc0c3d2be3e18a871c5a80187e560bb39cb14157 + flowy v1.0.0 deploy/docker-compose.yml abad48850d4362542d2f9cf5aad47c8a3237ae2f2576e028f67c6a6ba7536692 + flowy v1.0.0 deploy/docker-compose.tailscale.yml ab341609f947775a155d97532241fdcf1afdfc028641062aeb8ebbd59b0ba3ae + flowy v1.0.0 deploy/tailscale-serve.json a04bce87030f08074a9e9ebf5e79d0ce7507531eecdde73ad27e17098fb77a9d + flowy v1.0.0 deploy/.env.example 60a5432413fab9203a3f8dcc60f900b8448765ee708c8f1eede57e3e162ea6f5 + troye v0.1.0 deploy/install.sh 718645408f9941e7266d2f5ad2e944e01bcf5e3e8574b97e27e600e4e4f1affa + troye v0.1.0 deploy/docker-compose.yml 93d1aab609ec3c680bcdb4eb1e059bceca98cee1607df3cb66bf0f8e24e7ab4e + troye v0.1.0 deploy/docker-compose.tailscale.yml b2c98d1a3374481dac364231ac1559daa924e104a7bff04dcb4d936f06f5faea + troye v0.1.0 deploy/tailscale-serve.json a04bce87030f08074a9e9ebf5e79d0ce7507531eecdde73ad27e17098fb77a9d + troye v0.1.0 deploy/docker-compose.host.yml 04812d2b8d78e5e0f8af6c94d71be23e246c03a39446fa853a262e13fdacc53a + troye v0.1.0 deploy/.env.example de3c1e179895c0737793537a9c1c416ba20221fafe55d648d92a78ac9c7c4759 + shoppa v0.1.0 deploy/install.sh 63269c1b7a241135b529ab771b091783455de64b4ca677a98b51e1eec246278c + shoppa v0.1.0 deploy/docker-compose.yml 5713a851bf43dcce1dbfce424a2e5ad5ea7a1eaca640c5654c8161341d561a79 + shoppa v0.1.0 deploy/docker-compose.tailscale.yml 3c37d7a5b654176f3de8bee616f0e3164979e43d47d59dd33b38b29e4e215f5c + shoppa v0.1.0 deploy/tailscale-serve.json a04bce87030f08074a9e9ebf5e79d0ce7507531eecdde73ad27e17098fb77a9d + shoppa v0.1.0 deploy/.env.example b506f4d369127aa9ea32145a26d60a51f26f65704c43a1177ecd428abab084e2 + calio v0.1.0 deploy/install.sh 8208c1c22ff27ed90bbf1cbc211ca6360953e76c0070b2c9d613b0426db53079 + calio v0.1.0 deploy/docker-compose.yml f6ba1105ed63552c6c299fabd91a8a5cd05638ce1255e7c5e0399cdfa5540b4f + calio v0.1.0 deploy/docker-compose.tailscale.yml bbe8e5437acc4bfebfc6573f3ee5320a28cccc396d951587e23e1be550f54451 + calio v0.1.0 deploy/docker-compose.funnel.yml 3bb101d4cc6e33304d077312071cd5f5ec1f6ad6a70d2032ca313d3d0a1df49b + calio v0.1.0 deploy/tailscale-serve.json a04bce87030f08074a9e9ebf5e79d0ce7507531eecdde73ad27e17098fb77a9d + calio v0.1.0 deploy/tailscale-feed-serve.json ff25f48fd37d41560c3f588553451217b152b64a9a57342d60b5152606ffc343 + calio v0.1.0 deploy/.env.example 66c2ef5426e323127f4684ddf4d49f5abcea67948f39ddb3d795157ec39c11eb + # pins end + EOF +} + +sha256() { + if command -v sha256sum >/dev/null; then + sha256sum | cut -d' ' -f1 + elif command -v shasum >/dev/null; then + shasum -a 256 | cut -d' ' -f1 + else + die "sha256sum or shasum is needed to check the downloads" + fi +} + +# fetch_app APP DIR: downloads APP's pinned files into DIR, keeping their paths, +# and stops unless each one has its pinned SHA-256 +fetch_app() { + pins | grep -v '^#' | while read -r name tag path sum; do + [ "$name" = "$1" ] || continue + mkdir -p "$2/$(dirname "$path")" + fetch "https://raw.githubusercontent.com/selfhostit/$name/$tag/$path" >"$2/$path" || + die "could not download $path of $name $tag" + got=$(sha256 <"$2/$path") + [ "$got" = "$sum" ] || die "$path of $name $tag is not the reviewed file (SHA-256 $got); not installing it" + done + [ -n "$(pins | awk -v a="$1" '$1 == a')" ] || die "no pinned release of $1" +} + +# app_tag APP: the pinned release of APP +app_tag() { pins | awk -v a="$1" '$1 == a { print $2; exit }'; } + # --- Docker ------------------------------------------------------------------- # Synology keeps docker in /usr/local/bin, which is not always on the PATH @@ -161,24 +230,23 @@ fi # --- Install ------------------------------------------------------------------ +# Each app's files are downloaded into a scratch folder and checked first (fetch_app). # The plasto-based apps (Flowy, Troye, Shoppa, Calio) come with deploy/install.sh, which takes # the folder as its argument and PORT for a new install (or the next free port -# when that one is taken; Splitwiz's installer does the same). An empty TS_AUTHKEY -# skips their own Tailscale question: the shared node below covers them. +# when that one is taken; Splitwiz's installer does the same). Run from the scratch +# folder, they use the checked files next to them. An empty TS_AUTHKEY skips their +# own Tailscale question: the shared node below covers them. install_plasto() { # app port dir - fetch "https://raw.githubusercontent.com/selfhostit/$1/main/deploy/install.sh" | - $sudo env PORT="$2" TS_AUTHKEY= sh -s -- "$3" + $sudo env PORT="$2" TS_AUTHKEY= sh "$tmp/$1/deploy/install.sh" "$3" } install_splitwiz() { # port dir - fetch https://raw.githubusercontent.com/selfhostit/splitwiz/main/scripts/install.sh | - SPLITWIZ_DIR="$2" SPLITWIZ_PORT="$1" sh + SPLITWIZ_DIR="$2" SPLITWIZ_PORT="$1" sh "$tmp/splitwiz/scripts/install.sh" } install_should_we() { # port dir $sudo mkdir -p "$2/pb_data" - fetch https://raw.githubusercontent.com/selfhostit/should-we/main/docker-compose.yml | - $sudo tee "$2/docker-compose.yml" >/dev/null + $sudo cp "$tmp/should-we/docker-compose.yml" "$2/docker-compose.yml" if [ ! -f "$2/.env" ]; then port=$(free_port "$1") [ "$port" = "$1" ] || echo "Port $1 is taken, so should-we gets port $port." @@ -188,11 +256,14 @@ install_should_we() { # port dir (cd "$2" && $sudo docker compose pull --quiet && $sudo docker compose up -d --remove-orphans) } +tmp=$(mktemp -d) +trap 'rm -r "$tmp"' EXIT for app in $chosen; do port=$(info "$app" | cut -d'|' -f1) dir=$base/$app echo - say "== $app" + say "== $app $(app_tag "$app")" + fetch_app "$app" "$tmp/$app" $sudo mkdir -p "$dir" # Run each installer from its own folder, so none of them mistakes this # folder for a checkout of its repository. diff --git a/linux.md b/linux.md index 8cd6629..8e9e615 100644 --- a/linux.md +++ b/linux.md @@ -20,7 +20,7 @@ ssh you@homeserver.local ## 2. Install the apps ```sh -curl -fsSL https://raw.githubusercontent.com/selfhostit/docs/main/install.sh | sh +curl -fsSL https://github.com/selfhostit/docs/releases/latest/download/install.sh | sh ``` The installer: @@ -49,7 +49,7 @@ network or the internet. Then [install the apps on your phones](phones.md). Name the apps after `sh -s --`, and set the folder with `SELFHOSTIT_DIR`: ```sh -curl -fsSL https://raw.githubusercontent.com/selfhostit/docs/main/install.sh | +curl -fsSL https://github.com/selfhostit/docs/releases/latest/download/install.sh | SELFHOSTIT_DIR=/srv/apps sh -s -- splitwiz flowy ``` diff --git a/other-nas.md b/other-nas.md index 1076ddf..251571e 100644 --- a/other-nas.md +++ b/other-nas.md @@ -7,7 +7,7 @@ file can run them. Debian VM or LXC): sign in and run the installer, with `sudo` if your user can't use Docker. ```sh -curl -fsSL https://raw.githubusercontent.com/selfhostit/docs/main/install.sh | sudo sh +curl -fsSL https://github.com/selfhostit/docs/releases/latest/download/install.sh | sudo sh ``` On Unraid it suggests `/mnt/user/appdata`; anywhere else, pick a folder on your data pool diff --git a/scripts/pin-apps.sh b/scripts/pin-apps.sh new file mode 100755 index 0000000..312d622 --- /dev/null +++ b/scripts/pin-apps.sh @@ -0,0 +1,70 @@ +#!/bin/sh +# Pins install.sh to the newest release of every app: for each app it finds the +# release tag, downloads the files the installer uses from that tag, and writes +# their SHA-256 into the pins list in install.sh. Run it after an app's release, +# review the diff, and commit. +# +# scripts/pin-apps.sh pin every app to its newest release +# scripts/pin-apps.sh --check only check that the pinned files match GitHub (CI) +set -eu + +cd "$(dirname "$0")/.." +check=n +[ "${1:-}" = --check ] && check=y + +raw=https://raw.githubusercontent.com/selfhostit +sha256() { if command -v sha256sum >/dev/null; then sha256sum | cut -d' ' -f1; else shasum -a 256 | cut -d' ' -f1; fi; } + +latest_release() { # app + curl -fsSI "https://github.com/selfhostit/$1/releases/latest" | + sed -n 's|.*[Ll]ocation: .*/releases/tag/\([^[:space:]]*\).*|\1|p' | tr -d '\r' | head -n 1 +} + +# files APP TAG: the files the installer downloads for APP at TAG +files() { + case $1 in + splitwiz) echo "scripts/install.sh docker-compose.yml" ;; + should-we) echo "docker-compose.yml" ;; + *) + # the plasto apps list their deploy files in deploy/install.sh + list=$(curl -fsSL "$raw/$1/$2/deploy/install.sh" | sed -n 's/^files="\(.*\)"$/\1/p') + [ -n "$list" ] || { echo "no files= line in $1 $2 deploy/install.sh" >&2; exit 1; } + echo deploy/install.sh + for f in $list; do echo "deploy/$f"; done + ;; + esac +} + +if [ $check = y ]; then + bad=0 + sed -n '/^ # pins start/,/^ # pins end/p' install.sh | grep -v '# pins' | while read -r app tag path sum; do + got=$(curl -fsSL "$raw/$app/$tag/$path" | sha256) + if [ "$got" = "$sum" ]; then + echo "ok $app $tag $path" + else + echo "WRONG $app $tag $path: GitHub has $got" + exit 1 + fi + newest=$(latest_release "$app") + [ "$newest" = "$tag" ] || echo "note $app has a newer release, $newest; run scripts/pin-apps.sh" + done || bad=1 + exit $bad +fi + +apps=$(sed -n 's/^apps="\(.*\)"$/\1/p' install.sh) +list=$(mktemp) +for app in $apps; do + tag=$(latest_release "$app") + [ -n "$tag" ] || { echo "$app has no release" >&2; exit 1; } + for path in $(files "$app" "$tag"); do + printf '\t%s %s %s %s\n' "$app" "$tag" "$path" "$(curl -fsSL "$raw/$app/$tag/$path" | sha256)" >>"$list" + done + echo "$app $tag" +done +awk -v list="$list" ' + /^\t# pins start/ { print; while ((getline line < list) > 0) print line; skip = 1; next } + /^\t# pins end/ { skip = 0 } + !skip { print } +' install.sh >install.sh.tmp && mv install.sh.tmp install.sh +rm -f "$list" +echo "Pinned; review with: git diff install.sh" diff --git a/security.md b/security.md index 5cc8bad..c79148b 100644 --- a/security.md +++ b/security.md @@ -4,8 +4,10 @@ This page says how the selfhostit apps protect your data, what you are trusting install them, and what is not done yet. Each point links to the code or the setting, so you can check it rather than take it on trust. -Found a security problem? Please don't open a public issue. Write to **kalfasyan@pm.me** -with what you found and how to reproduce it; see [SECURITY.md](https://github.com/selfhostit/.github/blob/main/SECURITY.md). +Found a security problem? Please don't open a public issue. Use **Report a vulnerability** +on the app's Security tab on GitHub, or write to **kalfasyan@pm.me**. Every repository has a +`SECURITY.md` with the details: a reply within 7 days, critical fixes released within 7 days, +and a published advisory after each fix ([policy](https://github.com/selfhostit/.github/blob/main/SECURITY.md)). ## In short @@ -15,8 +17,12 @@ with what you found and how to reproduce it; see [SECURITY.md](https://github.co - **The apps are not on the internet.** Every app listens on `127.0.0.1` only. You reach them through [Tailscale](phones.md#tailscale-recommended), a private network of your own devices, or through your own reverse proxy. The guides say not to forward ports. +- **Locked-down containers:** a normal user, no Linux capabilities, a read-only filesystem. - **Nobody can claim a new install before you.** A fresh install prints a one-time setup code, and the first-run screen asks for it. +- **The installer installs reviewed releases,** checks every file it downloads against a + SHA-256 it pins, and is itself a signed release. +- **Every change is scanned** for vulnerabilities and leaked secrets, and again every week. - **The code is open** (MIT) and small enough to read: one Go backend on [PocketBase](https://pocketbase.io) and one Svelte web app per app. @@ -25,88 +31,146 @@ with what you found and how to reproduce it; see [SECURITY.md](https://github.co | What | Where | |---|---| | Apps listen on `127.0.0.1` only, so only this machine (and Tailscale or a reverse proxy on it) can connect | `ports:` in each app's compose file, for example [Flowy](https://github.com/selfhostit/flowy/blob/main/deploy/docker-compose.yml) | -| Containers drop every Linux capability and can't gain new privileges; Flowy and Troye also run as a normal user (Splitwiz and should-we not yet) | same compose files: `cap_drop: [ALL]`, `security_opt`, `user:` | -| One-time setup code: the first account (the owner) can only be made with the code printed at install, which is deleted after setup | `backend/app/setup.go` in [plasto](https://github.com/selfhostit/plasto/blob/main/backend/app/setup.go), Flowy, Troye and Splitwiz | +| Containers run as a normal user, drop every Linux capability, can't gain new privileges, and have a read-only filesystem apart from their data folder and `/tmp` | same compose files: `user:`, `cap_drop: [ALL]`, `security_opt`, `read_only: true`. Splitwiz and should-we start as root only to hand their data folder to that user, then switch ([code](https://github.com/selfhostit/splitwiz/blob/main/backend/app/privileges_linux.go)) | +| No Docker socket, no privileged mode, no host network for the apps | the compose files (only the optional Tailscale node uses the host network, to reach the apps on `127.0.0.1`) | +| One-time setup code: the first account (the owner) can only be made with the code printed at install, which is deleted after setup | `backend/app/setup.go` in [plasto](https://github.com/selfhostit/plasto/blob/main/backend/app/setup.go) and each app built on it, and Splitwiz | | Sign-in rate limits: 10 password attempts a minute for people and for the admin UI, 5 a minute for setup | `backend/migrations/*_rate_limits.go` in each app | | Access rules on every collection: people only see their own records or their group's | the collection API rules in `backend/migrations` | -| Content Security Policy, `nosniff` and a same-origin referrer on the web app, so injected scripts and outside resources don't load | `backend/app/headers.go` in each app | +| Content Security Policy (`script-src 'self'`), `nosniff`, `frame-ancestors 'none'` and a same-origin referrer, so injected scripts and outside resources don't load | `backend/app/headers.go` in each app | | Discreet reminders (Flowy, off by default): notifications only say "Reminder from Flowy", for a lock screen others can see | Flowy settings, `discreet_reminders` | | Nightly database backups, the last 7 kept | [Updates and backups](updates-and-backups.md#backups) | +## Sign-in, sessions and logs + +All of this is PocketBase's standard behavior, which the apps keep. + +- **Passwords** are stored only as bcrypt hashes, never in plain text or in a log. The + owner's password is also the password of the admin UI (`/_/`). +- **Sessions:** signing in gives the app a signed token, which it keeps in the browser's + local storage (not a cookie, so other sites can't make the browser send it). It expires + after 5 days, counted from when the app last renewed it: the apps built on plasto renew + it when you open them, Splitwiz asks you to sign in again. The Content + Security Policy above is what keeps a script that isn't the app's own from reading it. +- **Changing your password, or your email address, signs out every device** that was + signed in with the old one. Flowy also has "sign out on all devices". +- **should-we has no passwords:** people join a decision through its link, with a name. +- **Logs:** each app records its requests (time, method, address, status, IP address, and + who made it) in `pb_data/auxiliary.db` and keeps them 5 days. Request bodies, and with + them passwords and what you log in the apps, are not recorded. The log only leaves the + machine if you copy it. +- **On disk,** the database is not encrypted; see [What you are trusting](#what-you-are-trusting). + Backups can be: [Updates and backups](updates-and-backups.md#backups) shows how to + encrypt a copy. + ## How the code and images are built - Every app is built and tested in GitHub Actions on each pull request and push: Go and - web tests, type checks, and (for the plasto apps) an end-to-end run of the published - compose file. + web tests, type checks, and an end-to-end run of the published compose file that also + checks the container's user and read-only filesystem. +- **Security scans** (`.github/workflows/security.yml` in each app) on every pull request, + every push to `main` and every Monday: + [CodeQL](https://codeql.github.com) on the Go, web, Python and workflow code; + [govulncheck](https://go.dev/doc/security/vuln/) on the Go code the app can reach; + [Grype](https://github.com/anchore/grype) on the web and worker dependencies and on the + released images; GitHub's dependency review on new dependencies; and + [gitleaks](https://github.com/gitleaks/gitleaks) on the whole history. A known high or + critical vulnerability with a fix fails the check. CodeQL's findings are on each + repository's Security tab. +- GitHub's own protections are on in every repository: secret scanning with push + protection, Dependabot alerts and security updates, private vulnerability reporting, and + immutable releases (a published release's tag and files can't be changed). - Workflows run with a read-only token by default (`permissions: contents: read`); only the - job that publishes images gets `packages: write`. + jobs that publish images or releases get more. - Every third-party Action is pinned to a full commit hash, not a movable tag. - [Dependabot](https://docs.github.com/code-security/dependabot) keeps the Actions up to date - in every repository, and in plasto, Flowy and Troye also the Go, npm, Python and Docker + in every repository, and in the apps built on plasto also the Go, npm, Python and Docker dependencies. -- should-we also scans every push for committed secrets with gitleaks. - Images are published to [ghcr.io/selfhostit](https://github.com/orgs/selfhostit/packages) for amd64, arm64 and armv7. A release (`v1.2.3`, with notes in each app's `CHANGELOG.md`) - moves `latest`, which installs follow; changes on `main` only go to `edge`. + moves the major version tag (`:1`), which installs follow; changes on `main` only go to + `edge`. - Every published image has a signed build provenance attestation, which says which workflow run, repository and commit built it. Check one with the [GitHub CLI](https://cli.github.com): - `gh attestation verify oci://ghcr.io/selfhostit/flowy:latest --repo selfhostit/flowy`. + `gh attestation verify oci://ghcr.io/selfhostit/flowy:1 --repo selfhostit/flowy`. ## What you are trusting Being honest about this matters more than sounding safe. - **The installer and the images come from this GitHub organization.** The installer - downloads each app's installer or compose file from that app's `main` branch, and the apps - run the newest release. An attestation proves an image was built by the app's own CI from - a commit in its repository, not that the commit is harmless: someone who took over the - maintainer's GitHub account could still publish a bad release. + installs the app releases it pins, and the apps then update within their major version. + An attestation proves an image was built by the app's own CI from a commit in its + repository, and a pinned SHA-256 proves a file is the one that was reviewed, not that + either is harmless: someone who took over the maintainer's GitHub account could still + publish a bad release. - **Docker and the host.** The containers are locked down, but Docker itself runs as root, and anyone with access to the machine can read `pb_data`. The data on disk is not encrypted, so encrypt backups that leave the machine and wipe the disk before you sell it. - **Tailscale**, if you use it, to keep your private network private. Turn on two-factor sign-in for your Tailscale account and only share the machine with people you trust. + Its container follows Tailscale's `stable` image. - **What the apps send out:** push notifications go through your phone's browser push service (Apple, Google or Mozilla), encrypted end to end so the service can't read them. Troye looks up scanned barcodes on Open Food Facts and downloads its food table once a month. Nothing else leaves your server. -## Read the installer before you run it +## Read and check the installer before you run it -`curl ... | sh` runs a script straight from the internet. To read it first: +`curl ... | sh` runs a script straight from the internet. To read it and check it first: ```sh -curl -fsSLO https://raw.githubusercontent.com/selfhostit/docs/main/install.sh -less install.sh # read it +curl -fsSLO https://github.com/selfhostit/docs/releases/latest/download/install.sh +gh attestation verify install.sh --repo selfhostit/docs # built by this repository's release workflow +less install.sh # read it sh install.sh ``` +Without the GitHub CLI, compare `sha256sum install.sh` with `SHA256SUMS` on the +[release page](https://github.com/selfhostit/docs/releases/latest). + It is one plain shell script with comments. What it does: 1. Installs Docker with Docker's official script, only if Docker is missing and you say yes. 2. Makes one folder per app (in `~/selfhostit`, or `/volume1/docker` on a Synology). -3. For each app you pick, downloads and runs that app's own installer or compose file from - GitHub ([Flowy](https://github.com/selfhostit/flowy/blob/main/deploy/install.sh), +3. For each app you pick, downloads that app's installer or compose file **from the release + the installer pins**, checks each file against the SHA-256 written in the installer, and + stops if one doesn't match. Then it runs that installer + ([Flowy](https://github.com/selfhostit/flowy/blob/main/deploy/install.sh), [Troye](https://github.com/selfhostit/troye/blob/main/deploy/install.sh), [Shoppa](https://github.com/selfhostit/shoppa/blob/main/deploy/install.sh), [Calio](https://github.com/selfhostit/calio/blob/main/deploy/install.sh), - [Splitwiz](https://github.com/selfhostit/splitwiz/blob/main/scripts/install.sh), - [should-we](https://github.com/selfhostit/should-we/blob/main/docker-compose.yml)). - Read those too if you want to see every step. + [Splitwiz](https://github.com/selfhostit/splitwiz/blob/main/scripts/install.sh)) or + starts the compose file ([should-we](https://github.com/selfhostit/should-we/blob/main/docker-compose.yml)). 4. If you say yes, starts one Tailscale container and gives each app an `https://` address. -It uses `sudo` only to run Docker when your user can't, and changes nothing outside those -folders and Docker. Rather not run any script? Set the apps up from their compose files +The pinned list is updated with `scripts/pin-apps.sh` after an app's release, and CI checks +every week that each pinned file still matches GitHub. + +### What needs sudo + +The installer changes nothing outside the app folders and Docker. It uses `sudo` only for: + +- **Installing Docker**, if it is missing and you say yes: Docker's own script needs root. +- **Running Docker,** when your user isn't in the `docker` group (as right after installing + Docker): `docker compose pull`, `up`, `ps` and `inspect`, and `docker exec` into the + Tailscale container. +- **Writing in the apps' folder** when that needs it (`/volume1/docker` on a Synology): + making each app's folder, its `docker-compose.yml` and `.env`, and giving its `pb_data` to + the user the app runs as. +- **Reading each new app's one-time setup code** from its `pb_data`, to show it to you. + +Rather not run any script? Set the apps up from their compose files yourself: see [Synology without SSH](synology.md#without-ssh) and [other NAS and home servers](other-nas.md). ### Pin a version -To decide yourself when to update, pin an image to a release instead of `latest`, in the -app's `.env`: `FLOWY_IMAGE=ghcr.io/selfhostit/flowy:1.2.0` (one release) or `:1` (every 1.x), -or `SPLITWIZ_VERSION=1.2.0`. should-we follows its major version (`:2`). The releases and -what changed in each are on each repository's Releases page and in its `CHANGELOG.md`. +The compose files follow an app's major version (`:1`), so updates never jump to a new +major version on their own. To decide yourself when to update, pin a release in the app's +`.env`: `FLOWY_IMAGE=ghcr.io/selfhostit/flowy:1.2.0` or `SPLITWIZ_VERSION=1.2.0`. The +releases and what changed in each are on each repository's Releases page and in its +`CHANGELOG.md`. ## Project status @@ -122,9 +186,10 @@ is MIT licensed so anyone can keep building it, and your data is a SQLite databa These are planned, in roughly this order: -- Splitwiz and should-we running as a normal user inside their containers, like Flowy and Troye. - A "sign out everywhere" button in every app (Flowy has it), for a lost phone. - An OpenSSF Scorecard run on each repository. +- An independent security review. If you do one, please share what you find through the + reporting route above; findings and fixes will be published as advisories. ## Your part diff --git a/synology.md b/synology.md index 62a5543..8202232 100644 --- a/synology.md +++ b/synology.md @@ -14,7 +14,7 @@ shared folder. ## 2. Install the apps ```sh -curl -fsSL https://raw.githubusercontent.com/selfhostit/docs/main/install.sh | sudo sh +curl -fsSL https://github.com/selfhostit/docs/releases/latest/download/install.sh | sudo sh ``` It asks for your DSM password, which apps you want, and a folder (`/volume1/docker` by diff --git a/updates-and-backups.md b/updates-and-backups.md index 371a333..4a807d0 100644 --- a/updates-and-backups.md +++ b/updates-and-backups.md @@ -5,10 +5,11 @@ Run the installer again with the same apps: ```sh -curl -fsSL https://raw.githubusercontent.com/selfhostit/docs/main/install.sh | sh -s -- splitwiz flowy +curl -fsSL https://github.com/selfhostit/docs/releases/latest/download/install.sh | sh -s -- splitwiz flowy ``` -It downloads the newest version of each app and restarts it. Settings (`.env`) and data +It installs the app releases it pins (the newest when it was released; each file is checked +against its SHA-256) and restarts them. Settings (`.env`) and data (`pb_data`) are kept, and the database is upgraded on start. To update one app by hand, in its folder: @@ -22,7 +23,7 @@ To update apps over SSH without the installer, install the `selfhostit` command ```sh mkdir -p ~/bin -curl -fsSL https://raw.githubusercontent.com/selfhostit/docs/main/selfhostit -o ~/bin/selfhostit +curl -fsSL https://github.com/selfhostit/docs/releases/latest/download/selfhostit -o ~/bin/selfhostit chmod +x ~/bin/selfhostit echo 'export PATH="$HOME/bin:$PATH"' >> ~/.profile . ~/.profile @@ -65,7 +66,13 @@ Tailscale key, which lets whoever has it join your tailnet as that machine. encrypted if it goes off the NAS. - **Linux or a Pi:** copy the `pb_data` folders to another disk or computer from time to time, for example with `rsync -a --exclude tailscale ~/selfhostit/ otherhost:backups/selfhostit/`, - to an encrypted disk. + to an encrypted disk. Or make one encrypted file with [age](https://age-encryption.org), + which asks for a passphrase: + + ```sh + tar czf - --exclude tailscale -C ~/selfhostit . | age -p > selfhostit-$(date +%F).tar.gz.age + age -d selfhostit-2026-11-05.tar.gz.age | tar xzf - # to restore it + ``` Selling or throwing away the machine or its SD card? Delete the apps' folders and wipe the disk first; the data on it is not encrypted.