From 30f9fe00c762c33be93f8944f6b41eb5da22c803 Mon Sep 17 00:00:00 2001 From: psimaker Date: Wed, 10 Jun 2026 23:14:00 +0200 Subject: [PATCH 1/6] feat(notify): one-line installer and exact uid:gid permission fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/install.sh turns server setup into a single constant command (curl -fsSL https://vaultsync.eu/notify.sh | sh): it probes config.xml in the same order as the helper binary, reads the file owner so the helper always runs as that uid:gid (the #1 setup failure), and starts the helper via Docker — or, without Docker, installs a prebuilt release binary behind a systemd service (Linux) or launchd agent (macOS), verified against the release SHA256SUMS. Every path ends with the --doctor preflight; --dry-run previews all actions without changing anything. Nothing in the command is user-specific, so it is stable and short enough to type from the phone screen. The helper's permission error now names config.xml's actual owner and the exact -u uid:gid flag (falling back to the directory owner when even stat on the file is denied) instead of the generic uid-1000 hint. --- docs/relay-spec.md | 3 +- notify/README.md | 36 ++- notify/owner_other.go | 7 + notify/owner_unix.go | 28 ++ notify/scripts/install.sh | 436 ++++++++++++++++++++++++++++++++ notify/syncthing_config.go | 7 +- notify/syncthing_config_test.go | 41 ++- 7 files changed, 550 insertions(+), 8 deletions(-) create mode 100644 notify/owner_other.go create mode 100644 notify/owner_unix.go create mode 100755 notify/scripts/install.sh diff --git a/docs/relay-spec.md b/docs/relay-spec.md index 01efa9b..06171a5 100644 --- a/docs/relay-spec.md +++ b/docs/relay-spec.md @@ -48,7 +48,8 @@ In the app, the **Cloud Relay** tab → **Relay health & diagnostics** is the li ### Components **vaultsync-notify (Homeserver Container)** -- Docker container, runs alongside the user's Syncthing instance +- Docker container or prebuilt static binary, runs alongside the user's Syncthing instance +- One-line installer (`curl -fsSL https://vaultsync.eu/notify.sh | sh`): detects `config.xml`, runs the helper as the uid:gid owning it, picks Docker or a binary-backed system service, and finishes with the `--doctor` preflight - Subscribes to Syncthing's REST API event stream (`/rest/events`) - Filters for relevant outgoing-change signals: `LocalIndexUpdated`, plus `FolderCompletion` only when a peer still needs data - On file changes: sends a wake-up signal to the central relay diff --git a/notify/README.md b/notify/README.md index 08c2797..c855191 100644 --- a/notify/README.md +++ b/notify/README.md @@ -6,7 +6,25 @@ Small sidecar that watches your Syncthing instance and sends a **wake-up signal* --- -## 🚀 Quick start — Docker Compose (key-free) +## 🚀 Quick start — one line + +On the machine that runs Syncthing (server, NAS, or an always-on computer): + +```bash +curl -fsSL https://vaultsync.eu/notify.sh | sh +``` + +The installer ([`scripts/install.sh`](scripts/install.sh)) finds your `config.xml`, runs the helper as the uid:gid that owns it (the #1 setup failure), and starts it — as a Docker container when Docker is available, otherwise as a [prebuilt binary](#-prebuilt-binaries) behind a systemd service (Linux) or launchd agent (macOS). It ends with the helper's own `--doctor` preflight, so a misconfiguration fails loudly with the fix spelled out. + +The moment the helper starts it sends one wake-up, and VaultSync flips to **Cloud Relay active** on its own. + +- Skeptical of `curl | sh`? Append `-s -- --dry-run` to preview every action without changing anything, or read the script first. +- Config in a non-standard place (Synology/QNAP usually is)? `SYNCTHING_CONFIG=/path/to/config.xml curl -fsSL https://vaultsync.eu/notify.sh | sh` +- The script contains nothing user-specific — identity comes from your own Syncthing's Device ID at runtime. + +--- + +## 🐳 Docker Compose (key-free) Runs Syncthing and the helper together. The helper reads the Syncthing API key from the shared `config.xml`, so there's **no key to copy** — the only value you supply is `RELAY_URL`. @@ -16,8 +34,6 @@ cp .env.example .env # RELAY_URL defaults to the production relay docker compose up -d ``` -The moment the helper starts it sends one wake-up, and VaultSync flips to **Cloud Relay active** on its own. - > A plain `docker compose up` sends one **real** wake-up to production — intended for subscribers. Testing locally? Override `RELAY_URL` to a mock first (see the header of [`docker-compose.yml`](docker-compose.yml)). --- @@ -26,7 +42,7 @@ The moment the helper starts it sends one wake-up, and VaultSync flips to **Clou Syncthing running **natively on the host** (not in Compose)? Use either path — both auto-detect the key from `config.xml`. -**A. Paste-and-go `docker run`** (the command VaultSync shows after you subscribe): +**A. Paste-and-go `docker run`** (the manual alternative VaultSync shows after you subscribe): ```bash docker run -d --name vaultsync-notify --restart unless-stopped \ @@ -49,6 +65,18 @@ If detection fails, set `SYNCTHING_CONFIG=/path/to/config.xml` and rerun. --- +## 📦 Prebuilt binaries + +Every `notify-v*` release ships static binaries for **linux/amd64**, **linux/arm64**, **darwin/amd64**, **darwin/arm64**, and **windows/amd64** — no Docker, no Go toolchain. The [one-line installer](#-quick-start--one-line) downloads and verifies these automatically on Linux (systemd service) and macOS (launchd agent); grab them manually from the [releases page](https://github.com/psimaker/vaultsync/releases) for anything else, and check the download against the release's `SHA256SUMS`. + +Run manually — the only required value is `RELAY_URL`; the Syncthing key and URL are auto-detected from `config.xml`: + +```bash +RELAY_URL=https://relay.vaultsync.eu ./vaultsync-notify +``` + +--- + ## ⚙️ Environment variables | Variable | Required | Default | Description | diff --git a/notify/owner_other.go b/notify/owner_other.go new file mode 100644 index 0000000..e58667e --- /dev/null +++ b/notify/owner_other.go @@ -0,0 +1,7 @@ +//go:build !unix + +package main + +// fileOwner reports no owner on platforms without numeric uid/gid semantics +// (Windows); the permission error then falls back to its generic hint. +func fileOwner(string) (uid, gid uint32, ok bool) { return 0, 0, false } diff --git a/notify/owner_unix.go b/notify/owner_unix.go new file mode 100644 index 0000000..bdd040e --- /dev/null +++ b/notify/owner_unix.go @@ -0,0 +1,28 @@ +//go:build unix + +package main + +import ( + "os" + "path/filepath" + "syscall" +) + +// fileOwner returns the numeric uid:gid owning path, so a permission denial can +// carry the exact `-u uid:gid` fix instead of a generic "match the owner" hint. +// Stat only needs search permission on the parent directories — it works on the +// very file we were just denied read access to. When even the file's stat is +// denied (config dir itself is 0700), the directory's owner is reported +// instead: Syncthing keeps config.xml and its directory under the same user. +func fileOwner(path string) (uid, gid uint32, ok bool) { + for _, p := range []string{path, filepath.Dir(path)} { + info, err := os.Stat(p) + if err != nil { + continue + } + if st, ok := info.Sys().(*syscall.Stat_t); ok { + return st.Uid, st.Gid, true + } + } + return 0, 0, false +} diff --git a/notify/scripts/install.sh b/notify/scripts/install.sh new file mode 100755 index 0000000..0f0fe0b --- /dev/null +++ b/notify/scripts/install.sh @@ -0,0 +1,436 @@ +#!/usr/bin/env sh +# vaultsync-notify one-line installer. +# +# curl -fsSL https://vaultsync.eu/notify.sh | sh +# +# Skeptical of curl|sh? Quite right — append `-s -- --dry-run` to see every +# action without changing anything, or read this file first: +# https://github.com/psimaker/vaultsync/blob/main/notify/scripts/install.sh +# +# What it does, in order: +# 1. Finds Syncthing's config.xml (same probe order as the helper binary). +# 2. Reads the file owner, so the helper runs with exactly that uid:gid — +# config.xml is mode 0600, and a mismatched uid is the #1 setup failure. +# 3. Starts the helper: +# - Linux with Docker → ghcr.io/psimaker/vaultsync-notify container +# - Linux without → prebuilt binary + systemd service +# - macOS → prebuilt binary + launchd agent +# Docker on macOS is skipped on purpose: without host networking the +# container cannot reach a natively-running Syncthing on 127.0.0.1. +# 4. Runs the helper's --doctor preflight where possible, then verifies the +# service actually came up. +# +# The helper sends only your Syncthing Device ID to the relay — never file +# names, folder names, or content. There is nothing user-specific in this +# script: identity comes from your own Syncthing instance at runtime. +# +# Environment overrides (all optional): +# SYNCTHING_CONFIG path to config.xml when auto-detection misses it +# (Synology/QNAP usually need this) +# RELAY_URL relay endpoint (default: production relay) +# VAULTSYNC_NOTIFY_MODE auto|docker|binary (default: auto) +# VAULTSYNC_NOTIFY_IMAGE container image override (development) +set -eu + +RELAY_URL="${RELAY_URL:-https://relay.vaultsync.eu}" +MODE="${VAULTSYNC_NOTIFY_MODE:-auto}" +IMAGE="${VAULTSYNC_NOTIFY_IMAGE:-ghcr.io/psimaker/vaultsync-notify:latest}" +REPO="psimaker/vaultsync" +CONTAINER_NAME="vaultsync-notify" +DRY_RUN=0 + +for arg in "$@"; do + case "$arg" in + --dry-run) DRY_RUN=1 ;; + *) + printf 'ERROR: unknown argument: %s (only --dry-run is supported)\n' "$arg" >&2 + exit 1 + ;; + esac +done + +info() { + printf '%s\n' "$*" +} + +warn() { + printf 'WARN: %s\n' "$*" >&2 +} + +fail() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +# Execute a simple command, or print it instead under --dry-run. +run() { + if [ "$DRY_RUN" = 1 ]; then + info "[dry-run] would run: $*" + return 0 + fi + "$@" +} + +SUDO="" +need_root() { + # Returns a prefix that makes the following command run as root. sudo prompts + # on /dev/tty, so this works under `curl | sh` too. + if [ "$(id -u)" = 0 ]; then + SUDO="" + return 0 + fi + if command -v sudo >/dev/null 2>&1; then + SUDO="sudo" + return 0 + fi + return 1 +} + +# --- 1. Locate config.xml ---------------------------------------------------- + +find_syncthing_config() { + if [ -n "${SYNCTHING_CONFIG:-}" ]; then + if [ -e "${SYNCTHING_CONFIG}" ]; then + printf '%s\n' "$SYNCTHING_CONFIG" + return 0 + fi + fail "SYNCTHING_CONFIG is set to $SYNCTHING_CONFIG but no file exists there." + fi + + # Probe order mirrors the helper binary (notify/syncthing_config.go): + # current XDG state dir, legacy config dir, macOS, then container/system + # service layouts. + for candidate in \ + "${XDG_STATE_HOME:-$HOME/.local/state}/syncthing/config.xml" \ + "${XDG_CONFIG_HOME:-$HOME/.config}/syncthing/config.xml" \ + "$HOME/Library/Application Support/Syncthing/config.xml" \ + "/var/syncthing/config/config.xml" \ + "/config/config.xml" \ + "/var/syncthing/config.xml" \ + "/var/lib/syncthing/config.xml" \ + "/etc/syncthing/config.xml"; do + if [ -e "$candidate" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + return 1 +} + +# --- 2. File owner ----------------------------------------------------------- + +owner_of() { + # GNU stat (Linux) vs BSD stat (macOS). Reading the owner needs no read + # permission on the file itself, so this works even when the config is 0600 + # under another user. + path="$1" + if stat -c '%u:%g' "$path" 2>/dev/null; then + return 0 + fi + stat -f '%u:%g' "$path" 2>/dev/null +} + +# --- 3a. Docker path --------------------------------------------------------- + +resolve_docker() { + command -v docker >/dev/null 2>&1 || return 1 + if docker info >/dev/null 2>&1; then + DOCKER="docker" + return 0 + fi + if command -v sudo >/dev/null 2>&1 && sudo docker info >/dev/null 2>&1; then + DOCKER="sudo docker" + return 0 + fi + return 1 +} + +install_docker() { + config_dir=$(dirname -- "$CONFIG_PATH") + config_name=$(basename -- "$CONFIG_PATH") + + if $DOCKER ps -a --format '{{.Names}}' 2>/dev/null | grep -qx "$CONTAINER_NAME"; then + info "Replacing existing $CONTAINER_NAME container (it keeps no state)." + run $DOCKER rm -f "$CONTAINER_NAME" + fi + + info "Running preflight checks (doctor)..." + if [ "$DRY_RUN" = 1 ]; then + info "[dry-run] would run: $DOCKER run --rm --network host -u $OWNER -v $config_dir:/config:ro -e SYNCTHING_CONFIG=/config/$config_name -e RELAY_URL=$RELAY_URL $IMAGE --doctor" + else + $DOCKER run --rm --network host \ + -u "$OWNER" \ + -v "$config_dir":/config:ro \ + -e SYNCTHING_CONFIG="/config/$config_name" \ + -e RELAY_URL="$RELAY_URL" \ + "$IMAGE" --doctor \ + || fail "Preflight failed — see the messages above for the fix, then re-run this installer." + fi + + info "Starting the helper container..." + if [ "$DRY_RUN" = 1 ]; then + info "[dry-run] would run: $DOCKER run -d --name $CONTAINER_NAME --restart unless-stopped --network host -u $OWNER -v $config_dir:/config:ro -e SYNCTHING_CONFIG=/config/$config_name -e RELAY_URL=$RELAY_URL $IMAGE" + return 0 + fi + $DOCKER run -d --name "$CONTAINER_NAME" --restart unless-stopped \ + --network host \ + -u "$OWNER" \ + -v "$config_dir":/config:ro \ + -e SYNCTHING_CONFIG="/config/$config_name" \ + -e RELAY_URL="$RELAY_URL" \ + "$IMAGE" >/dev/null +} + +# --- 3b. Binary path --------------------------------------------------------- + +detect_asset() { + os=$(uname -s) + arch=$(uname -m) + case "$os" in + Linux) goos="linux" ;; + Darwin) goos="darwin" ;; + *) fail "Unsupported OS for the binary install: $os. Use Docker, or build from source (notify/README.md)." ;; + esac + case "$arch" in + x86_64 | amd64) goarch="amd64" ;; + aarch64 | arm64) goarch="arm64" ;; + *) fail "Unsupported CPU architecture: $arch (prebuilt binaries cover amd64 and arm64). Build from source: notify/README.md." ;; + esac + printf 'vaultsync-notify_%s_%s\n' "$goos" "$goarch" +} + +latest_notify_tag() { + # The repo also publishes app releases (v*); pick the newest notify-v* tag. + curl -fsSL "https://api.github.com/repos/$REPO/releases?per_page=30" \ + | grep -o '"tag_name": *"notify-v[^"]*"' \ + | head -1 \ + | sed 's/.*"\(notify-v[^"]*\)"/\1/' +} + +download_binary() { + asset="$1" + tag="$2" + dest="$3" + base="https://github.com/$REPO/releases/download/$tag" + + if [ "$DRY_RUN" = 1 ]; then + info "[dry-run] would download: $base/$asset -> $dest (and verify against $base/SHA256SUMS)" + return 0 + fi + + tmpdir=$(mktemp -d) + trap 'rm -rf "$tmpdir"' EXIT + curl -fsSL -o "$tmpdir/$asset" "$base/$asset" \ + || fail "Download failed: $base/$asset" + + # Verify against the release checksums when a SHA-256 tool exists. + if curl -fsSL -o "$tmpdir/SHA256SUMS" "$base/SHA256SUMS" 2>/dev/null; then + if command -v sha256sum >/dev/null 2>&1; then + (cd "$tmpdir" && grep " $asset\$" SHA256SUMS | sha256sum -c - >/dev/null) \ + || fail "Checksum mismatch for $asset — aborting." + elif command -v shasum >/dev/null 2>&1; then + (cd "$tmpdir" && grep " $asset\$" SHA256SUMS | shasum -a 256 -c - >/dev/null) \ + || fail "Checksum mismatch for $asset — aborting." + else + warn "No sha256sum/shasum found; skipping checksum verification." + fi + else + warn "Could not fetch SHA256SUMS; skipping checksum verification." + fi + + chmod 755 "$tmpdir/$asset" + if [ -w "$(dirname -- "$dest")" ]; then + mv "$tmpdir/$asset" "$dest" + else + need_root || fail "Cannot write $dest and sudo is unavailable. Re-run as root." + $SUDO mv "$tmpdir/$asset" "$dest" + fi +} + +run_doctor_binary() { + bin="$1" + # Preflight as the current user. If the config is unreadable for us (it + # belongs to a dedicated syncthing user), skip — the service runs as the + # owner, and the post-start check below still catches real failures. + if [ ! -r "$CONFIG_PATH" ]; then + info "Skipping doctor preflight ($CONFIG_PATH is not readable by $(id -un); the service will run as uid:gid $OWNER)." + return 0 + fi + info "Running preflight checks (doctor)..." + run env SYNCTHING_CONFIG="$CONFIG_PATH" RELAY_URL="$RELAY_URL" "$bin" --doctor \ + || fail "Preflight failed — see the messages above for the fix, then re-run this installer." +} + +install_systemd() { + bin="/usr/local/bin/vaultsync-notify" + unit="/etc/systemd/system/vaultsync-notify.service" + need_root || fail "Installing the systemd service needs root. Re-run with sudo, or use Docker." + + download_binary "$ASSET" "$TAG" "$bin" + run_doctor_binary "$bin" + + owner_uid=${OWNER%%:*} + owner_gid=${OWNER#*:} + unit_content="[Unit] +Description=VaultSync notify helper (Cloud Relay wake-ups) +Documentation=https://github.com/$REPO/blob/main/notify/README.md +After=network-online.target syncthing.service +Wants=network-online.target + +[Service] +ExecStart=$bin +Environment=RELAY_URL=$RELAY_URL +Environment=SYNCTHING_CONFIG=$CONFIG_PATH +User=$owner_uid +Group=$owner_gid +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target" + + if [ "$DRY_RUN" = 1 ]; then + info "[dry-run] would write $unit:" + printf '%s\n' "$unit_content" | sed 's/^/[dry-run] /' + info "[dry-run] would run: $SUDO systemctl daemon-reload && $SUDO systemctl enable --now vaultsync-notify" + return 0 + fi + + printf '%s\n' "$unit_content" | $SUDO tee "$unit" >/dev/null + $SUDO systemctl daemon-reload + $SUDO systemctl enable --now vaultsync-notify + + sleep 2 + if ! $SUDO systemctl is-active --quiet vaultsync-notify; then + $SUDO journalctl -u vaultsync-notify -n 20 --no-pager 2>/dev/null || true + fail "The service did not stay up — the log above explains why. Fix it and re-run this installer." + fi +} + +install_launchd() { + bin_dir="$HOME/.local/bin" + bin="$bin_dir/vaultsync-notify" + agent_dir="$HOME/Library/LaunchAgents" + plist="$agent_dir/eu.vaultsync.notify.plist" + label="eu.vaultsync.notify" + + run mkdir -p "$bin_dir" "$agent_dir" + download_binary "$ASSET" "$TAG" "$bin" + run_doctor_binary "$bin" + + plist_content=" + + + + Label + $label + ProgramArguments + + $bin + + EnvironmentVariables + + RELAY_URL + $RELAY_URL + SYNCTHING_CONFIG + $CONFIG_PATH + + RunAtLoad + + KeepAlive + + StandardErrorPath + /tmp/vaultsync-notify.log + +" + + if [ "$DRY_RUN" = 1 ]; then + info "[dry-run] would write $plist and (re)load it via launchctl" + return 0 + fi + + printf '%s\n' "$plist_content" >"$plist" + uid=$(id -u) + launchctl bootout "gui/$uid/$label" 2>/dev/null || true + launchctl bootstrap "gui/$uid" "$plist" + + sleep 2 + if ! launchctl print "gui/$uid/$label" >/dev/null 2>&1; then + fail "The launchd agent did not start — check /tmp/vaultsync-notify.log, fix it, and re-run this installer." + fi +} + +install_binary() { + command -v curl >/dev/null 2>&1 || fail "curl is required for the binary install." + ASSET=$(detect_asset) + TAG=$(latest_notify_tag) || TAG="" + [ -n "$TAG" ] || fail "Could not find a notify release on GitHub ($REPO). Check your network, or build from source: notify/README.md." + info "Installing $ASSET from release $TAG." + + case "$(uname -s)" in + Darwin) + install_launchd + ;; + Linux) + if command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ]; then + install_systemd + else + bin="./vaultsync-notify" + download_binary "$ASSET" "$TAG" "$bin" + run_doctor_binary "$bin" + warn "No systemd found — downloaded $bin but could not install a service." + info "Start it manually and keep it running:" + info " SYNCTHING_CONFIG=\"$CONFIG_PATH\" RELAY_URL=\"$RELAY_URL\" $bin" + exit 0 + fi + ;; + esac +} + +# --- Main -------------------------------------------------------------------- + +info "VaultSync Cloud Relay — server helper installer" +[ "$DRY_RUN" = 1 ] && info "(dry run — nothing will be changed)" + +if CONFIG_PATH=$(find_syncthing_config); then + info "Found Syncthing config: $CONFIG_PATH" +else + fail "Could not find Syncthing's config.xml. Is Syncthing installed on THIS machine? + - If it lives elsewhere, re-run with its path: SYNCTHING_CONFIG=/path/to/config.xml sh -s + - Synology/QNAP and custom setups: https://github.com/$REPO/blob/main/notify/README.md" +fi + +OWNER=$(owner_of "$CONFIG_PATH") || fail "Could not read the owner of $CONFIG_PATH." +info "Helper will run as uid:gid $OWNER (the owner of config.xml)." + +case "$MODE" in + docker) + resolve_docker || fail "VAULTSYNC_NOTIFY_MODE=docker, but Docker is not usable here." + install_docker + ;; + binary) + install_binary + ;; + auto) + # Docker on macOS cannot use host networking to reach a native Syncthing + # on 127.0.0.1, so macOS always takes the launchd binary path. + if [ "$(uname -s)" = "Linux" ] && resolve_docker; then + install_docker + else + install_binary + fi + ;; + *) + fail "Invalid VAULTSYNC_NOTIFY_MODE: $MODE (use auto, docker, or binary)." + ;; +esac + +info "" +if [ "$DRY_RUN" = 1 ]; then + info "Dry run complete — nothing was changed. Re-run without --dry-run to install." +else + info "Done. The helper has sent a first wake-up — within a minute, VaultSync on" + info "your iPhone shows \"Cloud Relay active\" (Relay tab). Nothing but your" + info "Syncthing Device ID ever leaves this machine." +fi diff --git a/notify/syncthing_config.go b/notify/syncthing_config.go index 0ff009d..d268d5f 100644 --- a/notify/syncthing_config.go +++ b/notify/syncthing_config.go @@ -181,8 +181,13 @@ func detectSyncthingFromCandidates(candidates []string) (detectedSyncthing, erro } // permissionDeniedError wraps os.ErrPermission (so errors.Is works upstream) and -// spells out the uid-match fix specific to Syncthing's 0600 config.xml. +// spells out the uid-match fix specific to Syncthing's 0600 config.xml. When the +// owner is stat-able the message carries the exact `-u uid:gid` to use, so the +// operator copies a fix instead of hunting for the right uid. func permissionDeniedError(path string, err error) error { + if uid, gid, ok := fileOwner(path); ok { + return fmt.Errorf("found Syncthing config at %s but cannot read it: %w — it is owned by uid:gid %d:%d; run the helper as that user (Docker: add -u %d:%d), or set SYNCTHING_API_KEY/SYNCTHING_API_URL explicitly", path, err, uid, gid, uid, gid) + } return fmt.Errorf("found Syncthing config at %s but cannot read it: %w — run the helper as the user that owns config.xml (the Docker images default to uid 1000), or set SYNCTHING_API_KEY/SYNCTHING_API_URL explicitly", path, err) } diff --git a/notify/syncthing_config_test.go b/notify/syncthing_config_test.go index 151cc91..da14a74 100644 --- a/notify/syncthing_config_test.go +++ b/notify/syncthing_config_test.go @@ -2,6 +2,7 @@ package main import ( "errors" + "fmt" "os" "path/filepath" "strings" @@ -151,8 +152,44 @@ func TestDetectSyncthingFromCandidatesPermissionDenied(t *testing.T) { if !errors.Is(err, os.ErrPermission) { t.Fatalf("error should wrap os.ErrPermission, got: %v", err) } - if !strings.Contains(err.Error(), "uid 1000") { - t.Fatalf("error should hint the uid fix, got: %v", err) + uid, gid, ok := fileOwner(p) + if !ok { + t.Fatalf("fileOwner should resolve the fixture owner on unix") + } + wantHint := fmt.Sprintf("-u %d:%d", uid, gid) + if !strings.Contains(err.Error(), wantHint) { + t.Fatalf("error should carry the exact %q fix, got: %v", wantHint, err) + } +} + +// An unreadable config *directory* (0700, foreign owner) denies the stat of +// config.xml itself; the owner hint must then come from the directory, which +// Syncthing keeps under the same user as the file. +func TestPermissionDeniedHintFallsBackToDirectoryOwner(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("running as root bypasses file permission checks") + } + p := writeFixture(t, deviceBeforeGUIConfigXML) + dir := filepath.Dir(p) + if err := os.Chmod(dir, 0o000); err != nil { + t.Fatalf("chmod: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(dir, 0o700) }) // let TempDir cleanup remove it + + _, err := detectSyncthingFromCandidates([]string{p}) + if err == nil { + t.Fatal("expected a permission error for a config behind an unreadable directory") + } + if !errors.Is(err, os.ErrPermission) { + t.Fatalf("error should wrap os.ErrPermission, got: %v", err) + } + uid, gid, ok := fileOwner(dir) + if !ok { + t.Fatalf("fileOwner should resolve the directory owner on unix") + } + wantHint := fmt.Sprintf("-u %d:%d", uid, gid) + if !strings.Contains(err.Error(), wantHint) { + t.Fatalf("error should carry the directory-derived %q fix, got: %v", wantHint, err) } } From 92bcf9eba9338f6d33bc9cbb0a7038c4081389b1 Mon Sep 17 00:00:00 2001 From: psimaker Date: Wed, 10 Jun 2026 23:14:11 +0200 Subject: [PATCH 2/6] ci: publish prebuilt notify binaries on notify-v* tags Cross-compiles vaultsync-notify for linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, and windows/amd64 (pure CGO_ENABLED=0 Go, no QEMU or goreleaser needed), generates SHA256SUMS, and attaches everything to a GitHub release for the tag (--latest=false so app releases keep the latest slot). install.sh resolves these assets for the no-Docker path; previously that path required a Go toolchain. --- .github/workflows/docker.yml | 47 ++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 37b13ff..5d2edeb 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -81,3 +81,50 @@ jobs: severity: HIGH,CRITICAL ignore-unfixed: true exit-code: "1" + + # Prebuilt helper binaries for the no-Docker path: install.sh downloads these + # (with checksum verification) and installs a systemd service or launchd + # agent. Pure CGO_ENABLED=0 Go, so a plain cross-compile loop covers every + # target — no QEMU, no goreleaser. + release-binaries: + name: Release Binaries + runs-on: ubuntu-latest + timeout-minutes: 10 + needs: notify-guard + if: startsWith(github.ref, 'refs/tags/notify-v') + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-go@v6 + with: + go-version-file: notify/go.mod + + - name: Cross-compile helper binaries + working-directory: notify + run: | + set -eu + mkdir -p dist + for target in linux/amd64 linux/arm64 darwin/amd64 darwin/arm64 windows/amd64; do + export GOOS="${target%/*}" GOARCH="${target#*/}" + out="dist/vaultsync-notify_${GOOS}_${GOARCH}" + [ "$GOOS" = windows ] && out="$out.exe" + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o "$out" . + done + (cd dist && sha256sum -- * > SHA256SUMS) + + # --latest=false keeps the app release (v*) as the repo's "latest"; + # install.sh resolves notify releases by tag prefix, not by latest. + - name: Publish release with binaries + env: + GH_TOKEN: ${{ github.token }} + run: | + set -eu + tag="${GITHUB_REF_NAME}" + if ! gh release view "$tag" >/dev/null 2>&1; then + gh release create "$tag" --verify-tag --latest=false \ + --title "vaultsync-notify ${tag#notify-v}" \ + --notes "Prebuilt \`vaultsync-notify\` helper binaries for the [Cloud Relay server setup](https://github.com/${GITHUB_REPOSITORY}/blob/main/notify/README.md). The one-line installer picks these up automatically; verify manual downloads against \`SHA256SUMS\`." + fi + gh release upload "$tag" --clobber notify/dist/* From 7b06ff82530d36e45e989edcb04bbd8ca62f4470 Mon Sep 17 00:00:00 2001 From: psimaker Date: Wed, 10 Jun 2026 23:14:11 +0200 Subject: [PATCH 3/6] feat(ios): lead Cloud Relay server setup with the one-line installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup screen now shows the constant installer command as Step 1 — it has no user-specific values, so there is nothing to edit and it is short enough to type into a server shell straight from the screen. The docker run snippet (with its /PATH/TO/syncthing placeholder and -u footgun) moves to a clearly-labeled manual alternative section. The footer explains what the installer does and points skeptics at --dry-run. Localized in en/de/es/zh-Hans. --- CHANGELOG.md | 11 +++ .../Views/RelayServerSetupView.swift | 81 ++++++++++++------- ios/VaultSync/de.lproj/Localizable.strings | 7 +- ios/VaultSync/en.lproj/Localizable.strings | 7 +- ios/VaultSync/es.lproj/Localizable.strings | 7 +- .../zh-Hans.lproj/Localizable.strings | 7 +- 6 files changed, 83 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16af8f6..41f13c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ All notable changes to VaultSync are documented here. --- +## [Unreleased] + +### Added + +- **Cloud Relay server setup is now one line** — `curl -fsSL https://vaultsync.eu/notify.sh | sh` on the server replaces the edit-this-command Docker snippet: the installer finds Syncthing's `config.xml` on its own, runs the helper as exactly the user that owns it (the #1 setup failure), starts it via Docker — or, without Docker, installs a prebuilt binary behind a systemd service (Linux) or launchd agent (macOS) — and finishes with the helper's `--doctor` preflight so problems surface immediately with the fix spelled out. Skeptics can append `--dry-run` to preview every action without changing anything. The in-app setup screen now leads with the one-liner; the manual `docker run` command remains as the alternative. Localized in English, German, Spanish, and Simplified Chinese. +- **Prebuilt `vaultsync-notify` binaries** — every `notify-v*` release now ships static helper binaries for linux/amd64, linux/arm64, macOS (Intel and Apple silicon), and Windows, with a `SHA256SUMS` file — running the helper without Docker no longer requires a Go toolchain. + +### Changed + +- **Permission errors now spell out the exact fix** — when the helper cannot read `config.xml`, the error names the file's actual owner and the exact `-u uid:gid` flag to use, instead of a generic "match the owner" hint (it also resolves the owner through an unreadable config directory). + ## [1.6.0] — 2026-06-10 ### Added diff --git a/ios/VaultSync/Views/RelayServerSetupView.swift b/ios/VaultSync/Views/RelayServerSetupView.swift index 44ee6e6..198feea 100644 --- a/ios/VaultSync/Views/RelayServerSetupView.swift +++ b/ios/VaultSync/Views/RelayServerSetupView.swift @@ -10,15 +10,25 @@ struct RelayServerSetupView: View { /// true the helper is confirmed running and we show a success banner. var isDelivering: Bool = false + @State private var installerCopied = false @State private var commandCopied = false - /// Self-contained command a user can paste into their server shell. The relay - /// URL is pre-filled and there is no API key to copy — the helper reads the - /// Syncthing key (and address) straight from the mounted config.xml. `--network - /// host` is kept so the address auto-inferred from config.xml (typically - /// 127.0.0.1:8384) reaches a same-host Syncthing. We interpolate - /// `productionRelayURL` (never the DEBUG-overridable `relayURL`) so a lab build - /// pointed at a mock can't leak that URL into the command shown to the user. + /// The primary setup path: a one-line installer that finds config.xml on the + /// server, runs the helper as the uid:gid owning it (the #1 setup failure), + /// and picks Docker or a prebuilt-binary service automatically. Nothing in + /// it is user-specific — identity comes from the server's own Syncthing at + /// runtime — so it is a stable constant, short enough to type by hand when + /// copy-paste can't reach the server shell. + private static let installerCommand = "curl -fsSL https://vaultsync.eu/notify.sh | sh" + + /// Self-contained command for users who prefer to run the container + /// themselves. The relay URL is pre-filled and there is no API key to copy — + /// the helper reads the Syncthing key (and address) straight from the + /// mounted config.xml. `--network host` is kept so the address auto-inferred + /// from config.xml (typically 127.0.0.1:8384) reaches a same-host Syncthing. + /// We interpolate `productionRelayURL` (never the DEBUG-overridable + /// `relayURL`) so a lab build pointed at a mock can't leak that URL into the + /// command shown to the user. private var dockerCommand: String { """ docker run -d --name vaultsync-notify --restart unless-stopped \\ @@ -48,25 +58,12 @@ struct RelayServerSetupView: View { } Section { - commandBox - Button { - UIPasteboard.general.string = dockerCommand - UINotificationFeedbackGenerator().notificationOccurred(.success) - commandCopied = true - Task { - try? await Task.sleep(for: .seconds(1.5)) - commandCopied = false - } - } label: { - Label( - commandCopied ? L10n.tr("Copied") : L10n.tr("Copy Command"), - systemImage: commandCopied ? "checkmark.circle" : "doc.on.doc" - ) - } + commandBox(Self.installerCommand, accessibilityLabelKey: "Installer command") + copyButton(for: Self.installerCommand, copied: $installerCopied) } header: { Text(L10n.tr("Step 1 — Run this on your server")) } footer: { - Text(L10n.tr("No API key needed — the helper reads it from Syncthing’s config.xml. Replace /PATH/TO/syncthing with your Syncthing config folder (often ~/.local/state/syncthing or ~/.config/syncthing). Permission error? Add -u : for the user that owns config.xml.")) + Text(L10n.tr("The installer finds your Syncthing config, sets the right permissions, and starts the helper — with Docker if available, otherwise as a system service. It’s open source; add --dry-run to preview every action without changing anything.")) } Section { @@ -77,25 +74,51 @@ struct RelayServerSetupView: View { } Section { - ExternalLinkButton(titleKey: "Full setup guide (Docker Compose or guided setup script)", url: DocURL.serverSetupGuide) + commandBox(dockerCommand, accessibilityLabelKey: "Server setup command") + copyButton(for: dockerCommand, copied: $commandCopied) + } header: { + Text(L10n.tr("Manual alternative — run the container yourself")) + } footer: { + Text(L10n.tr("No API key needed — the helper reads it from Syncthing’s config.xml. Replace /PATH/TO/syncthing with your Syncthing config folder (often ~/.local/state/syncthing or ~/.config/syncthing). Permission error? Add -u : for the user that owns config.xml.")) + } + + Section { + ExternalLinkButton(titleKey: "Full setup guide (Docker Compose, prebuilt binaries, NAS notes)", url: DocURL.serverSetupGuide) .font(.subheadline) } footer: { - Text(L10n.tr("Prefer Docker Compose or a guided setup script? The full guide covers both.")) + Text(L10n.tr("Prefer Docker Compose, a NAS package, or a plain binary? The full guide covers them all.")) } } .navigationTitle(L10n.tr("Set Up Your Server")) .navigationBarTitleDisplayMode(.inline) } - private var commandBox: some View { + private func commandBox(_ command: String, accessibilityLabelKey: String) -> some View { ScrollView(.horizontal, showsIndicators: true) { - Text(dockerCommand) + Text(command) .font(.vaultMono(.caption)) .textSelection(.enabled) .padding(VaultSpacing.m) } .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: VaultRadius.control, style: .continuous)) - .accessibilityLabel(L10n.tr("Server setup command")) - .accessibilityValue(dockerCommand) + .accessibilityLabel(L10n.tr(accessibilityLabelKey)) + .accessibilityValue(command) + } + + private func copyButton(for command: String, copied: Binding) -> some View { + Button { + UIPasteboard.general.string = command + UINotificationFeedbackGenerator().notificationOccurred(.success) + copied.wrappedValue = true + Task { + try? await Task.sleep(for: .seconds(1.5)) + copied.wrappedValue = false + } + } label: { + Label( + copied.wrappedValue ? L10n.tr("Copied") : L10n.tr("Copy Command"), + systemImage: copied.wrappedValue ? "checkmark.circle" : "doc.on.doc" + ) + } } } diff --git a/ios/VaultSync/de.lproj/Localizable.strings b/ios/VaultSync/de.lproj/Localizable.strings index 884d9cd..e86e379 100644 --- a/ios/VaultSync/de.lproj/Localizable.strings +++ b/ios/VaultSync/de.lproj/Localizable.strings @@ -525,10 +525,13 @@ "Copy Command" = "Befehl kopieren"; "Step 3 — Confirm it works" = "Schritt 3 – Bestätige, dass es funktioniert"; "Come back to VaultSync. The moment a change happens on your server, Relay Diagnostics will confirm that wake-ups are being delivered — that means everything works." = "Komm zurück zu VaultSync. Sobald sich auf deinem Server etwas ändert, bestätigt die Relay-Diagnose, dass Weck-Signale zugestellt werden – dann funktioniert alles."; -"Full setup guide (Docker Compose or guided setup script)" = "Vollständige Anleitung (Docker Compose oder geführtes Setup-Skript)"; -"Prefer Docker Compose or a guided setup script? The full guide covers both." = "Lieber Docker Compose oder ein geführtes Setup-Skript? Die vollständige Anleitung deckt beides ab."; +"Full setup guide (Docker Compose, prebuilt binaries, NAS notes)" = "Vollständige Anleitung (Docker Compose, vorkompilierte Binaries, NAS-Hinweise)"; +"Prefer Docker Compose, a NAS package, or a plain binary? The full guide covers them all." = "Lieber Docker Compose, ein NAS-Setup oder ein einfaches Binary? Die vollständige Anleitung deckt alles ab."; "Set Up Your Server" = "Server einrichten"; "Server setup command" = "Server-Einrichtungsbefehl"; +"Installer command" = "Installer-Befehl"; +"The installer finds your Syncthing config, sets the right permissions, and starts the helper — with Docker if available, otherwise as a system service. It’s open source; add --dry-run to preview every action without changing anything." = "Der Installer findet deine Syncthing-Konfiguration, setzt die richtigen Berechtigungen und startet den Helfer – mit Docker, falls vorhanden, sonst als Systemdienst. Er ist Open Source; mit --dry-run siehst du vorab jede Aktion, ohne dass etwas verändert wird."; +"Manual alternative — run the container yourself" = "Manuelle Alternative – Container selbst starten"; "Last wake-up" = "Letztes Weck-Signal"; "Auto-renews until canceled. Cancel anytime in Settings → Subscriptions." = "Verlängert sich automatisch bis zur Kündigung. Jederzeit kündbar unter Einstellungen → Abonnements."; "Cancel anytime in Settings → Subscriptions" = "Jederzeit kündbar unter Einstellungen → Abonnements"; diff --git a/ios/VaultSync/en.lproj/Localizable.strings b/ios/VaultSync/en.lproj/Localizable.strings index 518fae9..62c1f87 100644 --- a/ios/VaultSync/en.lproj/Localizable.strings +++ b/ios/VaultSync/en.lproj/Localizable.strings @@ -525,10 +525,13 @@ "Copy Command" = "Copy Command"; "Step 3 — Confirm it works" = "Step 3 — Confirm it works"; "Come back to VaultSync. The moment a change happens on your server, Relay Diagnostics will confirm that wake-ups are being delivered — that means everything works." = "Come back to VaultSync. The moment a change happens on your server, Relay Diagnostics will confirm that wake-ups are being delivered — that means everything works."; -"Full setup guide (Docker Compose or guided setup script)" = "Full setup guide (Docker Compose or guided setup script)"; -"Prefer Docker Compose or a guided setup script? The full guide covers both." = "Prefer Docker Compose or a guided setup script? The full guide covers both."; +"Full setup guide (Docker Compose, prebuilt binaries, NAS notes)" = "Full setup guide (Docker Compose, prebuilt binaries, NAS notes)"; +"Prefer Docker Compose, a NAS package, or a plain binary? The full guide covers them all." = "Prefer Docker Compose, a NAS package, or a plain binary? The full guide covers them all."; "Set Up Your Server" = "Set Up Your Server"; "Server setup command" = "Server setup command"; +"Installer command" = "Installer command"; +"The installer finds your Syncthing config, sets the right permissions, and starts the helper — with Docker if available, otherwise as a system service. It’s open source; add --dry-run to preview every action without changing anything." = "The installer finds your Syncthing config, sets the right permissions, and starts the helper — with Docker if available, otherwise as a system service. It’s open source; add --dry-run to preview every action without changing anything."; +"Manual alternative — run the container yourself" = "Manual alternative — run the container yourself"; "Last wake-up" = "Last wake-up"; "Auto-renews until canceled. Cancel anytime in Settings → Subscriptions." = "Auto-renews until canceled. Cancel anytime in Settings → Subscriptions."; "Cancel anytime in Settings → Subscriptions" = "Cancel anytime in Settings → Subscriptions"; diff --git a/ios/VaultSync/es.lproj/Localizable.strings b/ios/VaultSync/es.lproj/Localizable.strings index f969103..1213b85 100644 --- a/ios/VaultSync/es.lproj/Localizable.strings +++ b/ios/VaultSync/es.lproj/Localizable.strings @@ -525,10 +525,13 @@ "Copy Command" = "Copiar comando"; "Step 3 — Confirm it works" = "Paso 3: Confirma que funciona"; "Come back to VaultSync. The moment a change happens on your server, Relay Diagnostics will confirm that wake-ups are being delivered — that means everything works." = "Vuelve a VaultSync. En cuanto ocurra un cambio en tu servidor, Diagnóstico del Relay confirmará que se están entregando las señales de activación: eso significa que todo funciona."; -"Full setup guide (Docker Compose or guided setup script)" = "Guía completa (Docker Compose o script de configuración guiado)"; -"Prefer Docker Compose or a guided setup script? The full guide covers both." = "¿Prefieres Docker Compose o un script de configuración guiado? La guía completa cubre ambos."; +"Full setup guide (Docker Compose, prebuilt binaries, NAS notes)" = "Guía completa (Docker Compose, binarios precompilados, notas para NAS)"; +"Prefer Docker Compose, a NAS package, or a plain binary? The full guide covers them all." = "¿Prefieres Docker Compose, un NAS o un binario sin más? La guía completa lo cubre todo."; "Set Up Your Server" = "Configura tu servidor"; "Server setup command" = "Comando de configuración del servidor"; +"Installer command" = "Comando del instalador"; +"The installer finds your Syncthing config, sets the right permissions, and starts the helper — with Docker if available, otherwise as a system service. It’s open source; add --dry-run to preview every action without changing anything." = "El instalador encuentra tu configuración de Syncthing, establece los permisos correctos e inicia el asistente: con Docker si está disponible o, si no, como servicio del sistema. Es de código abierto; añade --dry-run para previsualizar cada acción sin cambiar nada."; +"Manual alternative — run the container yourself" = "Alternativa manual: ejecuta el contenedor tú mismo"; "Last wake-up" = "Última señal de activación"; "Auto-renews until canceled. Cancel anytime in Settings → Subscriptions." = "Se renueva automáticamente hasta que se cancele. Cancela cuando quieras en Ajustes → Suscripciones."; "Cancel anytime in Settings → Subscriptions" = "Cancela cuando quieras en Ajustes → Suscripciones"; diff --git a/ios/VaultSync/zh-Hans.lproj/Localizable.strings b/ios/VaultSync/zh-Hans.lproj/Localizable.strings index 65f98a5..74acde5 100644 --- a/ios/VaultSync/zh-Hans.lproj/Localizable.strings +++ b/ios/VaultSync/zh-Hans.lproj/Localizable.strings @@ -525,10 +525,13 @@ "Copy Command" = "复制命令"; "Step 3 — Confirm it works" = "第 3 步 — 确认其正常工作"; "Come back to VaultSync. The moment a change happens on your server, Relay Diagnostics will confirm that wake-ups are being delivered — that means everything works." = "回到 VaultSync。一旦你的服务器上发生改动,“Relay 诊断”就会确认唤醒信号正在送达——这表示一切正常。"; -"Full setup guide (Docker Compose or guided setup script)" = "完整设置指南(Docker Compose 或引导式设置脚本)"; -"Prefer Docker Compose or a guided setup script? The full guide covers both." = "更喜欢 Docker Compose 或引导式设置脚本?完整指南两者都涵盖。"; +"Full setup guide (Docker Compose, prebuilt binaries, NAS notes)" = "完整设置指南(Docker Compose、预编译二进制文件、NAS 说明)"; +"Prefer Docker Compose, a NAS package, or a plain binary? The full guide covers them all." = "更喜欢 Docker Compose、NAS 方案还是直接运行二进制文件?完整指南全部涵盖。"; "Set Up Your Server" = "设置你的服务器"; "Server setup command" = "服务器设置命令"; +"Installer command" = "安装程序命令"; +"The installer finds your Syncthing config, sets the right permissions, and starts the helper — with Docker if available, otherwise as a system service. It’s open source; add --dry-run to preview every action without changing anything." = "安装程序会找到你的 Syncthing 配置、设置正确的权限并启动助手——有 Docker 时使用 Docker,否则作为系统服务运行。它是开源的;添加 --dry-run 可预览每个操作而不做任何更改。"; +"Manual alternative — run the container yourself" = "手动方式——自行运行容器"; "Last wake-up" = "上次唤醒"; "Auto-renews until canceled. Cancel anytime in Settings → Subscriptions." = "自动续订,直至取消。可随时在“设置 → 订阅”中取消。"; "Cancel anytime in Settings → Subscriptions" = "可随时在“设置 → 订阅”中取消"; From bb0025d5092fca0155bdabde748330ad2a5df951 Mon Sep 17 00:00:00 2001 From: psimaker Date: Wed, 10 Jun 2026 23:20:06 +0200 Subject: [PATCH 4/6] fix(notify): harden installer; fold changelog into 1.6.0 - Guard $HOME for set -u (HOME-less contexts like containers/cron) - Verify the helper container stays running after start instead of reporting success on a restart loop; surface its log on failure - 1.6.0 has not shipped to the App Store yet, so the installer work rides in the existing 1.6.0 changelog section instead of Unreleased --- CHANGELOG.md | 12 ++---------- notify/scripts/install.sh | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 41f13c0..37e4df6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,21 +4,13 @@ All notable changes to VaultSync are documented here. --- -## [Unreleased] +## [1.6.0] — 2026-06-10 ### Added - **Cloud Relay server setup is now one line** — `curl -fsSL https://vaultsync.eu/notify.sh | sh` on the server replaces the edit-this-command Docker snippet: the installer finds Syncthing's `config.xml` on its own, runs the helper as exactly the user that owns it (the #1 setup failure), starts it via Docker — or, without Docker, installs a prebuilt binary behind a systemd service (Linux) or launchd agent (macOS) — and finishes with the helper's `--doctor` preflight so problems surface immediately with the fix spelled out. Skeptics can append `--dry-run` to preview every action without changing anything. The in-app setup screen now leads with the one-liner; the manual `docker run` command remains as the alternative. Localized in English, German, Spanish, and Simplified Chinese. - **Prebuilt `vaultsync-notify` binaries** — every `notify-v*` release now ships static helper binaries for linux/amd64, linux/arm64, macOS (Intel and Apple silicon), and Windows, with a `SHA256SUMS` file — running the helper without Docker no longer requires a Go toolchain. - -### Changed - -- **Permission errors now spell out the exact fix** — when the helper cannot read `config.xml`, the error names the file's actual owner and the exact `-u uid:gid` flag to use, instead of a generic "match the owner" hint (it also resolves the owner through an unreadable config directory). - -## [1.6.0] — 2026-06-10 - -### Added - +- **Helper permission errors now spell out the exact fix** — when the helper cannot read `config.xml`, the error names the file's actual owner and the exact `-u uid:gid` flag to use, instead of a generic "match the owner" hint (it also resolves the owner through an unreadable config directory). - **Missed wake-ups now catch up on their own** — A device that misses a Cloud Relay wake-up (offline too long, push expired) no longer stays stale until the next vault change: the server helper (`vaultsync-notify`) now re-sends a wake-up on a slow cadence while any of your devices still needs data (default every 6 hours; `STALE_RETRIGGER_SECONDS`, `0` disables). Fully synced devices never cause a push, and a wake-up that is already on its way is never duplicated. - **Overnight catch-up sync** — VaultSync now also schedules a long-running background task that iOS runs while the iPhone is charging with a network connection — typically overnight. It gets a multi-minute budget instead of the ~30 seconds of a normal background refresh, so large catch-ups complete on the charger instead of timing out. - **See what Cloud Relay actually delivers** — Relay Diagnostics now counts the wake-ups received in the last 7 days (stored only on your device, never reported anywhere), warns live when Low Power Mode is deferring silent pushes, and explains the most common silent killer: force-quitting VaultSync from the app switcher stops all wake-ups until the next manual launch. Localized in English, German, Spanish, and Simplified Chinese. diff --git a/notify/scripts/install.sh b/notify/scripts/install.sh index 0f0fe0b..7c08e1b 100755 --- a/notify/scripts/install.sh +++ b/notify/scripts/install.sh @@ -99,11 +99,12 @@ find_syncthing_config() { # Probe order mirrors the helper binary (notify/syncthing_config.go): # current XDG state dir, legacy config dir, macOS, then container/system - # service layouts. + # service layouts. ${HOME:-} keeps set -u happy in HOME-less contexts + # (containers, cron) — the unusable candidates simply never match. for candidate in \ - "${XDG_STATE_HOME:-$HOME/.local/state}/syncthing/config.xml" \ - "${XDG_CONFIG_HOME:-$HOME/.config}/syncthing/config.xml" \ - "$HOME/Library/Application Support/Syncthing/config.xml" \ + "${XDG_STATE_HOME:-${HOME:-}/.local/state}/syncthing/config.xml" \ + "${XDG_CONFIG_HOME:-${HOME:-}/.config}/syncthing/config.xml" \ + "${HOME:-}/Library/Application Support/Syncthing/config.xml" \ "/var/syncthing/config/config.xml" \ "/config/config.xml" \ "/var/syncthing/config.xml" \ @@ -180,6 +181,14 @@ install_docker() { -e SYNCTHING_CONFIG="/config/$config_name" \ -e RELAY_URL="$RELAY_URL" \ "$IMAGE" >/dev/null + + # Doctor passing makes a crash here unlikely, but verify the container + # actually stayed up rather than reporting success on a restart loop. + sleep 3 + if [ "$($DOCKER inspect -f '{{.State.Running}}' "$CONTAINER_NAME" 2>/dev/null)" != "true" ]; then + $DOCKER logs --tail 20 "$CONTAINER_NAME" >&2 || true + fail "The helper container did not stay up — the log above explains why. Fix it and re-run this installer." + fi } # --- 3b. Binary path --------------------------------------------------------- From 9b4d4eb23071e573a0903cd8a9008a53fd76ead3 Mon Sep 17 00:00:00 2001 From: psimaker Date: Wed, 10 Jun 2026 23:27:59 +0200 Subject: [PATCH 5/6] fix(notify): single clean error for a bad explicit SYNCTHING_CONFIG fail() inside the find_syncthing_config command substitution exits only the subshell, so the caller's generic not-found error printed on top of the specific one. Validate the explicit path in the main flow instead. Verified end-to-end on Linux (docker:dind, BusyBox sh): full install against a real Syncthing and the production relay (doctor passes with an unprovisioned device ID), idempotent re-run, owner-follow after chown, clean failure paths; dash and pipe-mode (curl|sh) checked; the checksum pipeline matches the release workflow output format. --- notify/scripts/install.sh | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/notify/scripts/install.sh b/notify/scripts/install.sh index 7c08e1b..516a06f 100755 --- a/notify/scripts/install.sh +++ b/notify/scripts/install.sh @@ -88,13 +88,13 @@ need_root() { # --- 1. Locate config.xml ---------------------------------------------------- +# NOTE: runs in a command substitution, so fail() here would exit only the +# subshell and the caller would print its generic error on top — explicit +# SYNCTHING_CONFIG is therefore validated in the main flow below. find_syncthing_config() { if [ -n "${SYNCTHING_CONFIG:-}" ]; then - if [ -e "${SYNCTHING_CONFIG}" ]; then - printf '%s\n' "$SYNCTHING_CONFIG" - return 0 - fi - fail "SYNCTHING_CONFIG is set to $SYNCTHING_CONFIG but no file exists there." + printf '%s\n' "$SYNCTHING_CONFIG" + return 0 fi # Probe order mirrors the helper binary (notify/syncthing_config.go): @@ -402,6 +402,10 @@ install_binary() { info "VaultSync Cloud Relay — server helper installer" [ "$DRY_RUN" = 1 ] && info "(dry run — nothing will be changed)" +if [ -n "${SYNCTHING_CONFIG:-}" ] && [ ! -e "${SYNCTHING_CONFIG}" ]; then + fail "SYNCTHING_CONFIG is set to $SYNCTHING_CONFIG but no file exists there." +fi + if CONFIG_PATH=$(find_syncthing_config); then info "Found Syncthing config: $CONFIG_PATH" else From 60b2f48699675e14ad55fb622cd28d4739fa0471 Mon Sep 17 00:00:00 2001 From: psimaker Date: Wed, 10 Jun 2026 23:46:14 +0200 Subject: [PATCH 6/6] fix: address CodeRabbit review on PR #39 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README/installer error message: the SYNCTHING_CONFIG override must prefix sh (the installer), not curl — in a pipeline the assignment only applied to curl, so the installer never saw it. Both examples now show the correct form and say so explicitly. - release-binaries job: persist-credentials: false on checkout (the job holds a contents:write token) and cache: false on setup-go so release binaries are never assembled from cacheable state another workflow run could have written. Not taken: pinning actions to commit SHAs — the repo convention is floating major tags managed by Dependabot across all workflows; switching pinning strategy is a repo-wide decision, not a PR-local one. --- .github/workflows/docker.yml | 7 +++++++ notify/README.md | 2 +- notify/scripts/install.sh | 3 ++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 5d2edeb..79df388 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -95,11 +95,18 @@ jobs: permissions: contents: write steps: + # This job holds a contents:write token for the release upload, so keep + # the checkout credential out of the workspace and skip the shared Go + # module cache — release binaries must not be assembled from cacheable + # state another workflow run could have written. - uses: actions/checkout@v6 + with: + persist-credentials: false - uses: actions/setup-go@v6 with: go-version-file: notify/go.mod + cache: false - name: Cross-compile helper binaries working-directory: notify diff --git a/notify/README.md b/notify/README.md index c855191..014168d 100644 --- a/notify/README.md +++ b/notify/README.md @@ -19,7 +19,7 @@ The installer ([`scripts/install.sh`](scripts/install.sh)) finds your `config.xm The moment the helper starts it sends one wake-up, and VaultSync flips to **Cloud Relay active** on its own. - Skeptical of `curl | sh`? Append `-s -- --dry-run` to preview every action without changing anything, or read the script first. -- Config in a non-standard place (Synology/QNAP usually is)? `SYNCTHING_CONFIG=/path/to/config.xml curl -fsSL https://vaultsync.eu/notify.sh | sh` +- Config in a non-standard place (typical on Synology/QNAP)? `curl -fsSL https://vaultsync.eu/notify.sh | SYNCTHING_CONFIG=/path/to/config.xml sh` — the variable must prefix `sh` (the installer), not `curl`. - The script contains nothing user-specific — identity comes from your own Syncthing's Device ID at runtime. --- diff --git a/notify/scripts/install.sh b/notify/scripts/install.sh index 516a06f..8d54911 100755 --- a/notify/scripts/install.sh +++ b/notify/scripts/install.sh @@ -410,7 +410,8 @@ if CONFIG_PATH=$(find_syncthing_config); then info "Found Syncthing config: $CONFIG_PATH" else fail "Could not find Syncthing's config.xml. Is Syncthing installed on THIS machine? - - If it lives elsewhere, re-run with its path: SYNCTHING_CONFIG=/path/to/config.xml sh -s + - If it lives elsewhere, re-run with its path (the variable must prefix sh, not curl): + curl -fsSL https://vaultsync.eu/notify.sh | SYNCTHING_CONFIG=/path/to/config.xml sh - Synology/QNAP and custom setups: https://github.com/$REPO/blob/main/notify/README.md" fi