From 20bc1aeb021faf3f0020d8d1f5a30c699f445095 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 12 Jul 2026 03:40:54 +0200 Subject: [PATCH] linux_configuration: self-hosted dufs cloud storage + KeePass WebDAV auto-sync Add setup_dufs_cloud.sh: a self-sustaining installer that stands up dufs (single Rust binary; web UI + built-in WebDAV) on 127.0.0.1:5000 behind the existing Caddy + DuckDNS exposure stack at a new subdomain, so files are reachable from mobile network over HTTPS. An event-driven systemd path unit mirrors ~/Keepass/Passwords.kdbx into the served folder on every change, so KeePassDX syncs the vault natively over WebDAV. Paranoid secret handling for the gitignored .dufs_cloud.conf (plaintext web password): ignored at repo-root + linux_configuration + **/ globs; a new check-no-dufs-conf pre-commit hook blocks it even against `git add -f`; the installer refuses to write it unless git check-ignore confirms it is ignored; the password is hashed via `openssl passwd -6 -stdin` (never in argv); and a cloud-root guard forbids serving home/.ssh/.config/.gnupg/the repo/root. Verified: dufs auth (401/200) + WebDAV (207) against the real v0.46.0 binary, idempotent Caddy/DuckDNS/unit-render logic, and a live phone-on-cellular test. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_0126QvdMrv2WNeUVShFiLTc3 --- .gitignore | 4 + .pre-commit-config.yaml | 7 + .../evidence/setup-dufs-cloud-2026-07-12.json | 57 +++ linux_configuration/.gitignore | 5 + .../single_use/features/setup_dufs_cloud.sh | 328 ++++++++++++++++++ .../single_use/features/systemd/dufs.service | 22 ++ .../features/systemd/keepass-cloud-sync.path | 12 + .../systemd/keepass-cloud-sync.service | 13 + meta/scripts/check_no_dufs_conf.sh | 25 ++ 9 files changed, 473 insertions(+) create mode 100644 docs/superpowers/evidence/setup-dufs-cloud-2026-07-12.json create mode 100755 linux_configuration/scripts/single_use/features/setup_dufs_cloud.sh create mode 100644 linux_configuration/scripts/single_use/features/systemd/dufs.service create mode 100644 linux_configuration/scripts/single_use/features/systemd/keepass-cloud-sync.path create mode 100644 linux_configuration/scripts/single_use/features/systemd/keepass-cloud-sync.service create mode 100755 meta/scripts/check_no_dufs_conf.sh diff --git a/.gitignore b/.gitignore index c190a4e1..3bee2bbc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ # Sensitive — must NEVER be committed (contains regex of home GPS coordinates etc.) .secret-patterns +# dufs cloud secret config — holds a plaintext web password; never commit anywhere. +**/.dufs_cloud.conf +.dufs_cloud.conf + # Compiled output /dist /tmp diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 301512bf..d3c39142 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -448,6 +448,13 @@ repos: language: script exclude: ^(\.secret-patterns|\.pre-commit-config\.yaml|.*\.geojson)$ + - id: check-no-dufs-conf + name: block dufs secret config (.dufs_cloud.conf) + entry: meta/scripts/check_no_dufs_conf.sh + language: script + always_run: true + pass_filenames: false + # =========================================================================== # COMMITIZEN - Conventional commits (optional) # =========================================================================== diff --git a/docs/superpowers/evidence/setup-dufs-cloud-2026-07-12.json b/docs/superpowers/evidence/setup-dufs-cloud-2026-07-12.json new file mode 100644 index 00000000..694cf526 --- /dev/null +++ b/docs/superpowers/evidence/setup-dufs-cloud-2026-07-12.json @@ -0,0 +1,57 @@ +{ + "intent": "Provide a self-hosted Google-Drive-style cloud on this Arch PC: a dufs file server (web UI + WebDAV) exposed publicly via the existing Caddy+DuckDNS stack, with the KeePassXC vault auto-mirrored into it so it is reachable and syncable from the phone on mobile network.", + "scope": [ + "linux_configuration/scripts/single_use/features/setup_dufs_cloud.sh (new self-sustaining installer)", + "linux_configuration/scripts/single_use/features/systemd/{dufs.service,keepass-cloud-sync.service,keepass-cloud-sync.path} (new units)", + "linux_configuration/.gitignore (ignore .dufs_cloud.conf)", + "Non-goals: no second Caddy/DuckDNS updater, no hand-edit of /etc/nftables.conf, no new mobile app (WebDAV covers KeePassDX)" + ], + "changes": [ + "Installer runs as the normal user with targeted sudo: installs a pinned, sha256-verified dufs v0.46.0 binary, writes a 0600 dufs.yaml (SHA-512 hashed auth, no anonymous access, bound to 127.0.0.1:5000), and installs+enables a system dufs.service.", + "Reuses the host exposure pattern from setup_gitea.sh: appends a marker-guarded reverse_proxy block for a new DuckDNS subdomain to the shared ~/gitea/Caddyfile and reloads gitea-caddy; extends the existing DuckDNS updater's domains= list (no new cron); opens 80/443 via setup_wireguard_ssh.sh allow-web only if not already open.", + "KeePass auto-sync: an event-driven systemd .path unit watches ~/Keepass/Passwords.kdbx and rsyncs ONLY that vault file into the served folder on every change (local .backup_* vaults stay off the public server); an initial mirror runs at install time.", + "Paranoid secret hardening: the plaintext password config is gitignored at 3 layers (repo root, linux_configuration/, **/ patterns); a new check-no-dufs-conf pre-commit hook blocks it even against `git add -f`; the installer refuses to write it unless `git check-ignore` confirms it is ignored; the password is hashed via openssl stdin (never in argv/ps); and a cloud-root guard forbids serving home/.ssh/.config/.gnupg/the repo/root." + ], + "verification": [ + { + "command": "bash -n setup_dufs_cloud.sh && shellcheck -x --severity=warning setup_dufs_cloud.sh", + "result": "pass", + "evidence": "syntax OK; shellcheck clean (no findings)." + }, + { + "command": "dufs v0.46.0 config + WebDAV smoke test (scratchpad, real binary)", + "result": "pass", + "evidence": "Flat-string auth 'user:$6$hash@/:rw' loads. no-auth->401, bad-pass->401, good->200; WebDAV PROPFIND->207, GET file->200 (returned content). Confirms schema, hashed auth, no anonymous access, WebDAV works." + }, + { + "command": "Isolated idempotency/render tests (scratchpad copies)", + "result": "pass", + "evidence": "DuckDNS domains sed adds label once and is skipped on re-run with token preserved; Caddy block appended once (1 cloud block) with tab indentation; unit rendering substitutes __USER__/__HOME__/__CLOUD_ROOT__ with no leftover placeholders." + }, + { + "command": "systemd-analyze verify rendered-*.service rendered-*.path", + "result": "pass", + "evidence": "No structural errors; only benign 'dufs not executable yet' (binary installed at deploy time) and sandbox 'unknown user' notes." + }, + { + "command": "Paranoid secret guards (functional tests)", + "result": "pass", + "evidence": "git check-ignore confirms .dufs_cloud.conf ignored at all 3 locations; check_no_dufs_conf.sh blocks a `git add -f`ed conf (exit 1) and cleanup leaves nothing staged; cloud-root guard rejects HOME/.ssh/.config/.gnupg/repo/`/`/`../` traversal and allows dedicated folders; openssl passwd -6 -stdin yields a $6$ hash without argv exposure." + }, + { + "command": "End-to-end deploy + phone-on-cellular acceptance", + "result": "pending", + "evidence": "Requires interactive password + sudo, so run by the user via '! ./setup_dufs_cloud.sh', plus a one-time manual DuckDNS subdomain registration. To be confirmed before commit." + } + ], + "risks": [ + "Public file server: mitigated by mandatory auth (SHA-512 hashed, no anonymous), HTTPS via Caddy, dufs bound to 127.0.0.1 only; the kdbx is itself master-password-encrypted.", + "Caddy block is appended to the shared Gitea Caddyfile; re-running setup_gitea.sh regenerates it and drops the block (re-run setup_dufs_cloud.sh to restore).", + "External reachability depends on a one-time manual DuckDNS subdomain creation and the existing router 80/443 forward." + ], + "rollback": [ + "sudo systemctl disable --now dufs.service keepass-cloud-sync.path; sudo rm /etc/systemd/system/{dufs.service,keepass-cloud-sync.service,keepass-cloud-sync.path}; sudo systemctl daemon-reload", + "Remove the cloud block from ~/gitea/Caddyfile and 'docker exec gitea-caddy caddy reload'; revert the domains= edit in ~/.joplin-server/duckdns-update.sh; sudo rm /usr/local/bin/dufs; rm -rf ~/.config/dufs ~/cloud", + "Validate after rollback: gitea still reachable at https://kuhy.duckdns.org and dufs.service is gone." + ] +} diff --git a/linux_configuration/.gitignore b/linux_configuration/.gitignore index 6f392a58..d14d68ee 100644 --- a/linux_configuration/.gitignore +++ b/linux_configuration/.gitignore @@ -8,6 +8,11 @@ scripts/features/.raspberry_pi.conf scripts/single_use/features/.wireguard_ssh.conf .wireguard_ssh.conf +# dufs cloud setup config (contains the plaintext web password) — never commit. +scripts/single_use/features/.dufs_cloud.conf +.dufs_cloud.conf +**/.dufs_cloud.conf + # Generated study materials (repo_to_study.sh output) study_materials/ **/study_materials/ diff --git a/linux_configuration/scripts/single_use/features/setup_dufs_cloud.sh b/linux_configuration/scripts/single_use/features/setup_dufs_cloud.sh new file mode 100755 index 00000000..ecaa87b5 --- /dev/null +++ b/linux_configuration/scripts/single_use/features/setup_dufs_cloud.sh @@ -0,0 +1,328 @@ +#!/bin/bash +# setup_dufs_cloud.sh — Self-hosted "cloud" storage (dufs) on this Arch PC. +# +# Stands up dufs (a single Rust binary: web UI + built-in WebDAV over a real +# folder), bound to 127.0.0.1 and fronted by the EXISTING Caddy reverse proxy +# (from setup_gitea.sh) for automatic public HTTPS at a new DuckDNS subdomain. +# Also mirrors the KeePassXC vault into the served folder on every change so it +# is always fetchable from the phone (KeePassDX syncs it natively over WebDAV). +# +# Reuses the host's existing exposure stack — it does NOT add a second Caddy, +# a second DuckDNS updater, or hand-edit /etc/nftables.conf. See +# .github/skills/self-hosted-service-exposure/SKILL.md. +# +# Run as your normal user (NOT root). It uses sudo only for pacman, the binary +# install, systemd units and the firewall flag. Idempotent — safe to re-run. +# +# Usage: +# ./setup_dufs_cloud.sh Install / update everything +# ./setup_dufs_cloud.sh -h Show help + +set -euo pipefail + +# --- Pinned dufs release (verified by sha256) -------------------------------- +readonly DUFS_VERSION="0.46.0" +readonly DUFS_SHA256="817769f726613194bcff9d0e3e481eaccc86ac11208857614f36a8c02f410977" +readonly DUFS_ASSET="dufs-v${DUFS_VERSION}-x86_64-unknown-linux-musl.tar.gz" +readonly DUFS_URL="https://github.com/sigoden/dufs/releases/download/v${DUFS_VERSION}/${DUFS_ASSET}" +readonly DUFS_BIN="/usr/local/bin/dufs" +readonly DUFS_PORT=5000 + +# --- Existing host infrastructure (from setup_gitea.sh / install_joplin.sh) -- +readonly CADDY_CONTAINER="gitea-caddy" +readonly CADDYFILE="${HOME}/gitea/Caddyfile" +readonly CADDYFILE_IN_CONTAINER="/etc/caddy/Caddyfile" +readonly DUCKDNS_UPDATER="${HOME}/.joplin-server/duckdns-update.sh" + +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" +readonly SCRIPT_DIR +readonly SYSTEMD_SRC="${SCRIPT_DIR}/systemd" +readonly WG_SCRIPT="${SCRIPT_DIR}/setup_wireguard_ssh.sh" +readonly CONFIG_FILE="${SCRIPT_DIR}/.dufs_cloud.conf" + +# Git repo root (used by the paranoid secret guards below). +REPO_ROOT="$(git -C "${SCRIPT_DIR}" rev-parse --show-toplevel 2>/dev/null || true)" +readonly REPO_ROOT + +# shellcheck source=../../lib/common.sh +# shellcheck disable=SC1091 +source "${SCRIPT_DIR}/../../lib/common.sh" + +# OS user that will own and run the service (not the dufs web-login user). +SYS_USER="$(get_actual_user)" +readonly SYS_USER + +# Config values (populated from CONFIG_FILE / prompts). +DUFS_USER="" +DUFS_PASSWORD="" +DUFS_SUBDOMAIN="" +CLOUD_ROOT="" + +die() { + log_error "$1" + exit 1 +} + +usage() { + grep -E '^#( |$)' "$0" | sed -E 's/^# ?//' + exit 0 +} + +# --- Step 1: preflight ------------------------------------------------------- +preflight() { + log_info "Preflight checks" + [[ ${EUID} -ne 0 ]] || die "Run as your normal user, not root (it will sudo where needed)." + + # Runtime deps; install any that are missing from the official repos. + install_missing_pacman_packages curl rsync inotify-tools openssl + + # The public-exposure stack must already exist (Caddy from setup_gitea.sh). + has_cmd docker || die "docker not found — run setup_gitea.sh first." + docker ps --format '{{.Names}}' | grep -qx "${CADDY_CONTAINER}" \ + || die "Caddy container '${CADDY_CONTAINER}' not running — run setup_gitea.sh first." + [[ -f ${CADDYFILE} ]] || die "Caddyfile ${CADDYFILE} missing — run setup_gitea.sh first." + [[ -x ${DUCKDNS_UPDATER} ]] || die "DuckDNS updater ${DUCKDNS_UPDATER} missing — run install_joplin.sh first." + log_ok "Host exposure stack present (docker + ${CADDY_CONTAINER} + DuckDNS updater)" +} + +# --- Step 2: configuration (gitignored .conf, prompt when empty) ------------- +load_config() { + if [[ -f ${CONFIG_FILE} ]]; then + # shellcheck source=/dev/null + source "${CONFIG_FILE}" + fi + DUFS_USER="${DUFS_USER:-}" + DUFS_PASSWORD="${DUFS_PASSWORD:-}" + DUFS_SUBDOMAIN="${DUFS_SUBDOMAIN:-}" + CLOUD_ROOT="${CLOUD_ROOT:-}" +} + +prompt_config() { + if [[ -z ${DUFS_USER} ]]; then + read -r -p "dufs web username [${SYS_USER}]: " DUFS_USER + DUFS_USER="${DUFS_USER:-${SYS_USER}}" + fi + if [[ -z ${DUFS_PASSWORD} ]]; then + read -r -s -p "dufs web password: " DUFS_PASSWORD + echo + [[ -n ${DUFS_PASSWORD} ]] || die "A password is required (this server is public)." + fi + if [[ -z ${DUFS_SUBDOMAIN} ]]; then + read -r -p "public subdomain [kuhy-cloud.duckdns.org]: " DUFS_SUBDOMAIN + DUFS_SUBDOMAIN="${DUFS_SUBDOMAIN:-kuhy-cloud.duckdns.org}" + fi + if [[ -z ${CLOUD_ROOT} ]]; then + read -r -p "cloud storage folder [${HOME}/cloud]: " CLOUD_ROOT + CLOUD_ROOT="${CLOUD_ROOT:-${HOME}/cloud}" + fi +} + +# Paranoid guard: never persist the plaintext password unless git is certain +# the file is ignored. This is checked against the live .gitignore, so a broken +# or removed ignore rule stops the write instead of leaking the secret. +assert_config_gitignored() { + [[ -n ${REPO_ROOT} ]] || die "Not inside a git repo — refusing to write the secret config." + if ! git -C "${REPO_ROOT}" check-ignore -q "${CONFIG_FILE}"; then + die "Refusing to write ${CONFIG_FILE}: it is NOT gitignored. Restore the .gitignore rule first — will not risk leaking the password." + fi +} + +save_config() { + assert_config_gitignored + # 0600, gitignored — holds the plaintext web password so re-runs stay + # non-interactive. Never committed (see .gitignore + the check-no-dufs-conf + # pre-commit hook, which blocks it even against `git add -f`). + umask 077 + { + printf 'DUFS_USER=%q\n' "${DUFS_USER}" + printf 'DUFS_PASSWORD=%q\n' "${DUFS_PASSWORD}" + printf 'DUFS_SUBDOMAIN=%q\n' "${DUFS_SUBDOMAIN}" + printf 'CLOUD_ROOT=%q\n' "${CLOUD_ROOT}" + } >"${CONFIG_FILE}" + chmod 600 "${CONFIG_FILE}" + log_ok "Saved config to ${CONFIG_FILE} (0600)" +} + +# --- Step 3: install the pinned, checksum-verified dufs binary --------------- +install_dufs() { + if [[ -x ${DUFS_BIN} ]] && "${DUFS_BIN}" --version 2>/dev/null | grep -q "${DUFS_VERSION}"; then + log_ok "dufs ${DUFS_VERSION} already installed" + return 0 + fi + log_info "Installing dufs ${DUFS_VERSION}" + local tmp + tmp="$(mktemp -d)" + # shellcheck disable=SC2064 + trap "rm -rf '${tmp}'" RETURN + curl -fsSL -o "${tmp}/${DUFS_ASSET}" "${DUFS_URL}" + echo "${DUFS_SHA256} ${tmp}/${DUFS_ASSET}" | sha256sum -c - \ + || die "dufs checksum mismatch — refusing to install." + tar -xzf "${tmp}/${DUFS_ASSET}" -C "${tmp}" + sudo install -m 0755 "${tmp}/dufs" "${DUFS_BIN}" + log_ok "Installed ${DUFS_BIN}" +} + +# Paranoid guard: never let dufs serve a directory that would expose secrets +# (home root, ~/.ssh, ~/.config — which holds dufs.yaml — ~/.gnupg, / , or the +# git repo which holds the gitignored .dufs_cloud.conf). +assert_cloud_root_safe() { + local resolved home_r + resolved="$(readlink -m "${CLOUD_ROOT}")" + home_r="$(readlink -m "${HOME}")" + case "${resolved}" in + "${home_r}" | / | "${home_r}/.ssh" | "${home_r}/.ssh/"* \ + | "${home_r}/.config" | "${home_r}/.config/"* \ + | "${home_r}/.gnupg" | "${home_r}/.gnupg/"*) + die "Unsafe cloud folder '${CLOUD_ROOT}' — it would expose sensitive files. Use a dedicated folder like ${HOME}/cloud." ;; + esac + if [[ -n ${REPO_ROOT} && (${resolved} == "${REPO_ROOT}" || ${resolved} == "${REPO_ROOT}/"*) ]]; then + die "Unsafe cloud folder '${CLOUD_ROOT}' — it is inside the git repo (which holds the secret config). Use a dedicated folder." + fi +} + +# --- Step 4: served folder --------------------------------------------------- +create_serve_dir() { + assert_cloud_root_safe + ensure_dir "${CLOUD_ROOT}" + ensure_dir "${CLOUD_ROOT}/Keepass" + chmod 700 "${CLOUD_ROOT}" + log_ok "Cloud folder ${CLOUD_ROOT} ready" +} + +# --- Step 5: dufs config (SHA-512 hashed password, no anonymous access) ------- +write_dufs_config() { + local cfg_dir="${HOME}/.config/dufs" + local cfg="${cfg_dir}/dufs.yaml" + local hash + # Read the password from stdin so it never appears in the process argv (ps). + hash="$(printf '%s' "${DUFS_PASSWORD}" | openssl passwd -6 -stdin)" + ensure_dir "${cfg_dir}" + umask 077 + cat >"${cfg}" </dev/null +} + +install_units() { + log_info "Installing systemd units" + render_unit dufs.service + render_unit keepass-cloud-sync.service + render_unit keepass-cloud-sync.path + sudo systemctl daemon-reload + sudo systemctl enable --now dufs.service + log_ok "dufs.service enabled and started" +} + +# --- Step 7: Caddy reverse-proxy block (append + reload) --------------------- +configure_caddy() { + if grep -qE "^${DUFS_SUBDOMAIN} \{" "${CADDYFILE}"; then + log_ok "Caddyfile already has a block for ${DUFS_SUBDOMAIN}" + else + log_info "Adding Caddy block for ${DUFS_SUBDOMAIN}" + # NOTE: appended to the shared Gitea Caddyfile. Re-running setup_gitea.sh + # regenerates that file and drops this block — re-run setup_dufs_cloud.sh + # to restore it. Caddyfile uses tab indentation. + printf '\n%s {\n\treverse_proxy 127.0.0.1:%s\n}\n' "${DUFS_SUBDOMAIN}" "${DUFS_PORT}" \ + >>"${CADDYFILE}" + fi + docker exec "${CADDY_CONTAINER}" caddy reload --config "${CADDYFILE_IN_CONTAINER}" \ + || die "caddy reload failed — check 'docker logs ${CADDY_CONTAINER}'." + log_ok "Caddy reloaded (auto-HTTPS will be issued once the subdomain resolves)" +} + +# --- Step 8: DuckDNS subdomain (extend existing updater; no new cron) -------- +configure_duckdns() { + local label="${DUFS_SUBDOMAIN%.duckdns.org}" + if grep -qE "domains=[^&\"']*${label}([,&\"']|$)" "${DUCKDNS_UPDATER}"; then + log_ok "DuckDNS updater already includes '${label}'" + else + log_info "Adding '${label}' to the existing DuckDNS updater" + sed -i -E "s|(domains=)([^&\"']*)|\1\2,${label}|" "${DUCKDNS_UPDATER}" + fi + # Refresh now (harmless if the subdomain isn't registered yet). + bash "${DUCKDNS_UPDATER}" || log_warn "DuckDNS update returned non-zero (register the subdomain first)." + if getent hosts "${DUFS_SUBDOMAIN}" >/dev/null 2>&1; then + log_ok "${DUFS_SUBDOMAIN} resolves" + else + log_warn "${DUFS_SUBDOMAIN} does not resolve yet — create the '${label}' subdomain in the DuckDNS dashboard." + fi +} + +# --- Step 9: firewall (open 80/443 via the owning script, idempotent) ------- +ensure_firewall() { + if sudo nft list ruleset 2>/dev/null | grep -qE 'dport \{[^}]*80[^}]*443'; then + log_ok "Firewall already allows tcp/80 + tcp/443" + elif [[ -f ${WG_SCRIPT} ]]; then + log_info "Opening 80/443 via setup_wireguard_ssh.sh allow-web" + sudo bash "${WG_SCRIPT}" allow-web + else + log_warn "setup_wireguard_ssh.sh not found — ensure tcp/80+443 are open." + fi +} + +# --- Step 10: KeePass auto-sync (initial mirror + enable the path watcher) --- +setup_autosync() { + log_info "Setting up KeePass auto-sync watcher" + # Mirror only the vault file (not the whole dir with local .backup_* vaults). + rsync -a "${HOME}/Keepass/Passwords.kdbx" "${CLOUD_ROOT}/Keepass/" + sudo systemctl enable --now keepass-cloud-sync.path + log_ok "Vault mirrored and watcher enabled" +} + +# --- Step 11: report --------------------------------------------------------- +print_report() { + local url="https://${DUFS_SUBDOMAIN}" + cat <&2 + printf ' %s\n' "$bad" >&2 + echo "" >&2 + echo "This file must stay local only. Unstage it: git rm --cached " >&2 + exit 1 +fi + +exit 0