From 9bb11e47c7dc89f718c5b3b443aa993402964445 Mon Sep 17 00:00:00 2001 From: daniel-c-harvey Date: Thu, 4 Jun 2026 10:45:50 -0400 Subject: [PATCH] feat(deploy): add full CD pipeline infrastructure for DeepDrftHome Four Gitea workflows (deploy-public, deploy-manager, deploy-api, package-install) and a complete deploy/ folder: bootstrap, install, ssh-wrapper, three deploy scripts, setup-step10-creds, three systemd user units, two nginx vhost templates. Models Skipper's deploy infrastructure with key deviations: flat csproj paths, dual PostgreSQL databases, FileDatabase vault directory (never touched on deploy), EF bundle covers DeepDrftContext only (AuthBlocks self-migrates at boot), deploy-api reads DB connection from host credentials not CI args. --- .gitea/workflows/deploy-api.yml | 127 +++++++ .gitea/workflows/deploy-manager.yml | 82 +++++ .gitea/workflows/deploy-public.yml | 86 +++++ .gitea/workflows/package-install.yml | 65 ++++ deploy/bootstrap.sh | 121 +++++++ deploy/deploy-api.sh | 113 ++++++ deploy/deploy-manager.sh | 46 +++ deploy/deploy-public.sh | 46 +++ deploy/install.sh | 474 +++++++++++++++++++++++++ deploy/nginx/deepdrft-manager.conf | 19 + deploy/nginx/deepdrft-public.conf | 19 + deploy/setup-step10-creds.sh | 247 +++++++++++++ deploy/ssh-wrapper.sh | 48 +++ deploy/systemd/deepdrftapi.service | 37 ++ deploy/systemd/deepdrftmanager.service | 30 ++ deploy/systemd/deepdrftpublic.service | 30 ++ 16 files changed, 1590 insertions(+) create mode 100644 .gitea/workflows/deploy-api.yml create mode 100644 .gitea/workflows/deploy-manager.yml create mode 100644 .gitea/workflows/deploy-public.yml create mode 100644 .gitea/workflows/package-install.yml create mode 100644 deploy/bootstrap.sh create mode 100644 deploy/deploy-api.sh create mode 100644 deploy/deploy-manager.sh create mode 100644 deploy/deploy-public.sh create mode 100644 deploy/install.sh create mode 100644 deploy/nginx/deepdrft-manager.conf create mode 100644 deploy/nginx/deepdrft-public.conf create mode 100644 deploy/setup-step10-creds.sh create mode 100644 deploy/ssh-wrapper.sh create mode 100644 deploy/systemd/deepdrftapi.service create mode 100644 deploy/systemd/deepdrftmanager.service create mode 100644 deploy/systemd/deepdrftpublic.service diff --git a/.gitea/workflows/deploy-api.yml b/.gitea/workflows/deploy-api.yml new file mode 100644 index 0000000..641a7a3 --- /dev/null +++ b/.gitea/workflows/deploy-api.yml @@ -0,0 +1,127 @@ +name: Deploy DeepDrftAPI + +on: + push: + branches: [master, dev] + paths: + - 'DeepDrftAPI/**' + - 'DeepDrftData/**' + - 'DeepDrftContent/**' + - 'DeepDrftModels/**' + - '.gitea/workflows/deploy-api.yml' + - 'deploy/systemd/deepdrftapi.service' + +jobs: + build: + name: Build, Publish & Bundle + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Install dotnet-ef tool + run: | + dotnet tool install --global dotnet-ef + echo "$HOME/.dotnet/tools" >> $GITHUB_PATH + + - name: Restore + run: dotnet restore DeepDrftAPI/DeepDrftAPI.csproj -r linux-x64 + + # Build with RID so --no-build is valid for the Publish step below. + - name: Build + run: dotnet build DeepDrftAPI/DeepDrftAPI.csproj -c Release -r linux-x64 --self-contained --no-restore + + # Add: dotnet test DeepDrftTests/DeepDrftTests.csproj when API integration tests exist. + + - name: Publish (self-contained linux-x64) + run: | + dotnet publish DeepDrftAPI/DeepDrftAPI.csproj \ + -c Release \ + -r linux-x64 \ + --self-contained \ + --no-build \ + -o DeepDrftAPI/publish + + # DeepDrftContextFactory reads environment/connections.json at design time. + # Write a parseable dummy so the factory does not throw during bundle construction. + # The bundle only needs the provider type, not a live database connection. + - name: Write dummy connections file for EF bundle + run: | + mkdir -p DeepDrftAPI/environment + echo '{"ConnectionStrings":{"DefaultConnection":"Host=localhost;Database=dummy;Username=dummy","Auth":"Host=localhost;Database=dummy;Username=dummy"}}' > DeepDrftAPI/environment/connections.json + + # EF bundle: self-contained binary that applies DeepDrftContext migrations on the host + # without the .NET SDK. AuthBlocks' Identity DB is NOT covered here — it self-migrates + # via UseAuthBlocksStartupAsync() on first boot. + - name: Bundle EF migrations + run: | + dotnet ef migrations bundle \ + --project DeepDrftData/DeepDrftData.csproj \ + --startup-project DeepDrftAPI/DeepDrftAPI.csproj \ + --context DeepDrftContext \ + --configuration Release \ + --output deepdrft-migrations-bundle \ + --self-contained \ + -r linux-x64 + + - name: Package + run: tar -czf deepdrft-api.tar.gz -C DeepDrftAPI/publish . + + - name: Upload artifacts (archive + bundle) + uses: actions/upload-artifact@v3 + with: + name: deepdrft-api + path: | + deepdrft-api.tar.gz + deepdrft-migrations-bundle + retention-days: 1 + + deploy: + name: Deploy + needs: build + runs-on: ubuntu-24.04 + if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/dev') + env: + DEPLOY_HOST: ${{ github.ref == 'refs/heads/master' && 'prod.cerebellumsoftworks.com' || 'dch7.cerebellumsoftworks.com' }} + steps: + - uses: actions/checkout@v4 + + - name: Download artifacts + uses: actions/download-artifact@v3 + with: + name: deepdrft-api + path: staging/ + + - name: Install SSH tooling + run: apt-get update && apt-get install -y --no-install-recommends openssh-client rsync + + - name: Configure SSH + env: + PROD_KEY: ${{ secrets.DEEPDRFT_PROD_SSH_DEPLOY }} + BETA_KEY: ${{ secrets.DEEPDRFT_DCH7_SSH_DEPLOY }} + run: | + mkdir -p ~/.ssh + if [ "$DEPLOY_HOST" = "prod.cerebellumsoftworks.com" ]; then + echo "$PROD_KEY" > ~/.ssh/deepdrft_ed25519 + else + echo "$BETA_KEY" > ~/.ssh/deepdrft_ed25519 + fi + chmod 600 ~/.ssh/deepdrft_ed25519 + ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts || { echo "ssh-keyscan failed for $DEPLOY_HOST"; exit 1; } + + - name: Rsync archive, bundle, and unit file to staging + run: | + chmod +x staging/deepdrft-migrations-bundle + rsync -e "ssh -i ~/.ssh/deepdrft_ed25519 -o StrictHostKeyChecking=yes" \ + staging/deepdrft-api.tar.gz \ + staging/deepdrft-migrations-bundle \ + deploy/systemd/deepdrftapi.service \ + deepdrft@$DEPLOY_HOST: + + - name: Trigger deploy on host + run: | + ssh -i ~/.ssh/deepdrft_ed25519 -o StrictHostKeyChecking=yes \ + deepdrft@$DEPLOY_HOST deploy-api diff --git a/.gitea/workflows/deploy-manager.yml b/.gitea/workflows/deploy-manager.yml new file mode 100644 index 0000000..9bdc652 --- /dev/null +++ b/.gitea/workflows/deploy-manager.yml @@ -0,0 +1,82 @@ +name: Deploy DeepDrftManager + +on: + push: + branches: [master, dev] + paths: + - 'DeepDrftManager/**' + - 'DeepDrftShared.Client/**' + - 'DeepDrftModels/**' + - '.gitea/workflows/deploy-manager.yml' + +jobs: + build: + name: Build & Publish + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + # No test project scoped to DeepDrftManager — add one and wire it here when ready. + + - name: Publish (self-contained linux-x64) + run: | + dotnet publish DeepDrftManager/DeepDrftManager.csproj \ + -c Release \ + -r linux-x64 \ + --self-contained \ + -o DeepDrftManager/publish + + - name: Package + run: tar -czf deepdrft-manager.tar.gz -C DeepDrftManager/publish . + + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: deepdrft-manager + path: deepdrft-manager.tar.gz + retention-days: 1 + + deploy: + name: Deploy + needs: build + runs-on: ubuntu-24.04 + if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/dev') + env: + DEPLOY_HOST: ${{ github.ref == 'refs/heads/master' && 'prod.cerebellumsoftworks.com' || 'dch7.cerebellumsoftworks.com' }} + steps: + - name: Download artifact + uses: actions/download-artifact@v3 + with: + name: deepdrft-manager + + - name: Install SSH tooling + run: apt-get update && apt-get install -y --no-install-recommends openssh-client rsync + + - name: Configure SSH + env: + PROD_KEY: ${{ secrets.DEEPDRFT_PROD_SSH_DEPLOY }} + BETA_KEY: ${{ secrets.DEEPDRFT_DCH7_SSH_DEPLOY }} + run: | + mkdir -p ~/.ssh + if [ "$DEPLOY_HOST" = "prod.cerebellumsoftworks.com" ]; then + echo "$PROD_KEY" > ~/.ssh/deepdrft_ed25519 + else + echo "$BETA_KEY" > ~/.ssh/deepdrft_ed25519 + fi + chmod 600 ~/.ssh/deepdrft_ed25519 + ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts || { echo "ssh-keyscan failed for $DEPLOY_HOST"; exit 1; } + + - name: Rsync archive to staging + run: | + rsync -e "ssh -i ~/.ssh/deepdrft_ed25519 -o StrictHostKeyChecking=yes" \ + deepdrft-manager.tar.gz \ + deepdrft@$DEPLOY_HOST: + + - name: Trigger deploy on host + run: | + ssh -i ~/.ssh/deepdrft_ed25519 -o StrictHostKeyChecking=yes \ + deepdrft@$DEPLOY_HOST deploy-manager diff --git a/.gitea/workflows/deploy-public.yml b/.gitea/workflows/deploy-public.yml new file mode 100644 index 0000000..ebb28d2 --- /dev/null +++ b/.gitea/workflows/deploy-public.yml @@ -0,0 +1,86 @@ +name: Deploy DeepDrftPublic + +on: + push: + branches: [master, dev] + paths: + - 'DeepDrftPublic/**' + - 'DeepDrftPublic.Client/**' + - 'DeepDrftShared.Client/**' + - 'DeepDrftModels/**' + - '.gitea/workflows/deploy-public.yml' + +jobs: + build: + name: Build & Publish + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Install wasm-tools workload + run: dotnet workload install wasm-tools + + # No test project scoped to DeepDrftPublic — add one and wire it here when ready. + + - name: Publish (self-contained linux-x64) + run: | + dotnet publish DeepDrftPublic/DeepDrftPublic.csproj \ + -c Release \ + -r linux-x64 \ + --self-contained \ + -o DeepDrftPublic/publish + + - name: Package + run: tar -czf deepdrft-public.tar.gz -C DeepDrftPublic/publish . + + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: deepdrft-public + path: deepdrft-public.tar.gz + retention-days: 1 + + deploy: + name: Deploy + needs: build + runs-on: ubuntu-24.04 + if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/dev') + env: + DEPLOY_HOST: ${{ github.ref == 'refs/heads/master' && 'prod.cerebellumsoftworks.com' || 'dch7.cerebellumsoftworks.com' }} + steps: + - name: Download artifact + uses: actions/download-artifact@v3 + with: + name: deepdrft-public + + - name: Install SSH tooling + run: apt-get update && apt-get install -y --no-install-recommends openssh-client rsync + + - name: Configure SSH + env: + PROD_KEY: ${{ secrets.DEEPDRFT_PROD_SSH_DEPLOY }} + BETA_KEY: ${{ secrets.DEEPDRFT_DCH7_SSH_DEPLOY }} + run: | + mkdir -p ~/.ssh + if [ "$DEPLOY_HOST" = "prod.cerebellumsoftworks.com" ]; then + echo "$PROD_KEY" > ~/.ssh/deepdrft_ed25519 + else + echo "$BETA_KEY" > ~/.ssh/deepdrft_ed25519 + fi + chmod 600 ~/.ssh/deepdrft_ed25519 + ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts || { echo "ssh-keyscan failed for $DEPLOY_HOST"; exit 1; } + + - name: Rsync archive to staging + run: | + rsync -e "ssh -i ~/.ssh/deepdrft_ed25519 -o StrictHostKeyChecking=yes" \ + deepdrft-public.tar.gz \ + deepdrft@$DEPLOY_HOST: + + - name: Trigger deploy on host + run: | + ssh -i ~/.ssh/deepdrft_ed25519 -o StrictHostKeyChecking=yes \ + deepdrft@$DEPLOY_HOST deploy-public diff --git a/.gitea/workflows/package-install.yml b/.gitea/workflows/package-install.yml new file mode 100644 index 0000000..f5ea7c3 --- /dev/null +++ b/.gitea/workflows/package-install.yml @@ -0,0 +1,65 @@ +name: Package install tarball + +# Authentication: uses the built-in gitea.token (auto-injected per run). +# No manual secret required. + +on: + push: + branches: [master, dev] + paths: + - 'deploy/**' + +jobs: + package: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build install tarball + run: | + tar -czf deepdrft-install.tar.gz \ + --exclude=deploy/bootstrap.sh \ + --transform 's|^deploy/||' \ + deploy/ + + - name: Create Gitea release + env: + GITEA_TOKEN: ${{ gitea.token }} + GITEA_SERVER_URL: ${{ gitea.server_url }} + GITEA_REPO: ${{ gitea.repository }} + run: | + SHORT_SHA="${GITHUB_SHA::7}" + if [ "$GITHUB_REF" = "refs/heads/master" ]; then + TAG="install-$(date +%Y%m%d)-${SHORT_SHA}" + PRERELEASE="false" + else + TAG="beta-$(date +%Y%m%d)-${SHORT_SHA}" + PRERELEASE="true" + fi + + # Create the release via Gitea API + RELEASE_URL=$(curl -s -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + "${GITEA_SERVER_URL}/api/v1/repos/${GITEA_REPO}/releases" \ + -d "{ + \"tag_name\": \"${TAG}\", + \"name\": \"Install package ${TAG}\", + \"body\": \"Self-contained install tarball. Commit: ${GITHUB_SHA}\", + \"draft\": false, + \"prerelease\": ${PRERELEASE} + }" | jq -r '.upload_url') + + # upload_url from Gitea includes {?name,label} template — strip it + UPLOAD_BASE="${RELEASE_URL%\{*\}}" + + curl -s -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + "${UPLOAD_BASE}?name=deepdrft-install.tar.gz" \ + --data-binary @deepdrft-install.tar.gz + + echo "Released as tag: ${TAG}" + echo "Asset URL: ${GITEA_SERVER_URL}/${GITEA_REPO}/releases/download/${TAG}/deepdrft-install.tar.gz" diff --git a/deploy/bootstrap.sh b/deploy/bootstrap.sh new file mode 100644 index 0000000..4cd10db --- /dev/null +++ b/deploy/bootstrap.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# deploy/bootstrap.sh +# +# Self-contained first-time host bootstrap for the DeepDrft vertical. +# Curl this onto a bare Ubuntu host and pipe to bash as root: +# +# For private repos (typical): download the release asset from the Gitea UI +# on your local machine, scp it to the host, then run with INSTALL_PKG_PATH: +# +# scp deepdrft-install.tar.gz root@:/tmp/ +# curl -fsSL .../deploy/bootstrap.sh | INSTALL_PKG_PATH=/tmp/deepdrft-install.tar.gz sudo bash +# +# For public repos: set INSTALL_PKG_URL to the release asset URL instead. +# +# curl -fsSL https://gitea.example.com/danielharvey/DeepDrftHome/raw/branch/master/deploy/bootstrap.sh \ +# | INSTALL_PKG_URL=https://gitea.example.com/danielharvey/DeepDrftHome/releases/download//deepdrft-install.tar.gz \ +# sudo bash + +set -euo pipefail + +# ── Output helpers ────────────────────────────────────────────────────────────── +step() { echo; echo "=== $* ==="; } +die() { echo; echo "[ERROR] $*" >&2; exit 1; } + +# ── Preflight ────────────────────────────────────────────────────────────────── +step "DeepDrft bootstrap" + +if [[ "${EUID}" -ne 0 ]]; then + die "Must run as root: sudo bash bootstrap.sh" +fi + +# ── OS prereqs ───────────────────────────────────────────────────────────────── +step "Installing OS prereqs" + +export DEBIAN_FRONTEND=noninteractive +apt-get update -qq +apt-get install -y --no-install-recommends \ + postgresql \ + nginx \ + rsync \ + openssl \ + jq \ + wget \ + ca-certificates + +echo " [ok] apt packages installed" + +# Ensure postgres is running — may need a moment after fresh install +systemctl enable postgresql +systemctl start postgresql +sleep 2 +if ! pg_isready &>/dev/null; then + die "PostgreSQL did not start. Check: journalctl -u postgresql" +fi +echo " [ok] PostgreSQL running" + +# ── Install package resolution ───────────────────────────────────────────────── +step "Install package" + +TARBALL="/tmp/deepdrft-install.tar.gz" +EXTRACT_DIR="/tmp/deepdrft-install" + +if [[ -n "${INSTALL_PKG_PATH:-}" ]]; then + # Case 1: caller supplied an explicit local path + if [[ ! -f "${INSTALL_PKG_PATH}" ]]; then + die "INSTALL_PKG_PATH is set but file not found: ${INSTALL_PKG_PATH}" + fi + echo " Using local package: ${INSTALL_PKG_PATH}" + TARBALL="${INSTALL_PKG_PATH}" +elif [[ -n "${INSTALL_PKG_URL:-}" ]]; then + # Case 2: URL supplied — download it + echo " Downloading ${INSTALL_PKG_URL} ..." + wget -q --show-progress -O "${TARBALL}" "${INSTALL_PKG_URL}" + echo " [ok] Downloaded to ${TARBALL}" +else + # Nothing provided — prompt, explaining the private-repo workflow + echo + echo " The install package is a Gitea release asset (deepdrft-install.tar.gz)." + echo " Because the repo is private, download it from the Gitea UI on your local" + echo " machine and scp it to this host before continuing." + echo + echo " Option A — local path (recommended for private repos):" + echo " scp deepdrft-install.tar.gz root@:/tmp/" + echo " Then re-run: INSTALL_PKG_PATH=/tmp/deepdrft-install.tar.gz bash bootstrap.sh" + echo + echo " Option B — URL (only works if the release is publicly accessible):" + echo " Enter the URL below." + echo + read -rp " Local path to tarball (leave blank to enter a URL): " INSTALL_PKG_PATH_INPUT + if [[ -n "${INSTALL_PKG_PATH_INPUT}" ]]; then + if [[ ! -f "${INSTALL_PKG_PATH_INPUT}" ]]; then + die "File not found: ${INSTALL_PKG_PATH_INPUT}" + fi + echo " Using local package: ${INSTALL_PKG_PATH_INPUT}" + TARBALL="${INSTALL_PKG_PATH_INPUT}" + else + echo + read -rp " INSTALL_PKG_URL: " INSTALL_PKG_URL + if [[ -z "${INSTALL_PKG_URL}" ]]; then + die "INSTALL_PKG_URL is required." + fi + echo " Downloading ${INSTALL_PKG_URL} ..." + wget -q --show-progress -O "${TARBALL}" "${INSTALL_PKG_URL}" + echo " [ok] Downloaded to ${TARBALL}" + fi +fi + +rm -rf "${EXTRACT_DIR}" +mkdir -p "${EXTRACT_DIR}" +tar -xzf "${TARBALL}" -C "${EXTRACT_DIR}" +echo " [ok] Extracted to ${EXTRACT_DIR}" + +# ── Hand off to install.sh ───────────────────────────────────────────────────── +step "Running install.sh" + +if [[ ! -f "${EXTRACT_DIR}/install.sh" ]]; then + die "install.sh not found in ${EXTRACT_DIR}. Tarball layout unexpected." +fi + +chmod +x "${EXTRACT_DIR}/install.sh" +exec bash "${EXTRACT_DIR}/install.sh" diff --git a/deploy/deploy-api.sh b/deploy/deploy-api.sh new file mode 100644 index 0000000..31e52d7 --- /dev/null +++ b/deploy/deploy-api.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# Installed to: /opt//bin/deploy-api.sh +# Deployed by: deploy-api Gitea Actions workflow (ssh forced-command) +# +# Expects in ${APP_HOME}/staging/: +# deepdrft-api.tar.gz -- published self-contained linux-x64 binary tree +# deepdrft-migrations-bundle -- self-contained EF bundle (pre-built in CI) +# deepdrftapi.service -- systemd unit file (optional) +# +# Migrations are applied BEFORE the service restarts via the EF bundle binary. +# The bundle covers DeepDrftContext (track metadata DB) only. +# AuthBlocks' Identity DB is NOT bundled here — UseAuthBlocksStartupAsync() applies +# its own migrations and seeds the admin user on first service boot. +# +# The DB connection string is read from the host credential file at: +# ${APP_HOME}/.config/credentials/connections.json +# The DefaultConnection value is extracted and passed to the bundle. +# No DB name is passed by CI — the host credential is the source of truth. +# +# The vault directory (${APP_HOME}/api/deepdrft/vaults) is NEVER touched on deploy. +# It is persistent host state created by the installer. Do not add cleanup logic here. +# +# Paths are derived at runtime — no hardcoded usernames or home dirs. +# APP_HOME comes from $HOME (sshd sets this for the app user). + +set -euo pipefail + +export XDG_RUNTIME_DIR="/run/user/$(id -u)" + +APP_HOME="${HOME}" +export PATH="${APP_HOME}/.local/bin:${PATH}" + +STAGING="${APP_HOME}/staging" +APPROOT="${APP_HOME}/api/deepdrft" +ARCHIVE="deepdrft-api.tar.gz" +BUNDLE="${STAGING}/deepdrft-migrations-bundle" +CREDS_FILE="${APP_HOME}/.config/credentials/connections.json" + +echo "[deploy-api] $(date -u +%Y-%m-%dT%H:%M:%SZ) starting" + +# ── Read DB connection string from host credential ──────────────────────── +if [[ ! -f "${CREDS_FILE}" ]]; then + echo "[deploy-api] ERROR: credentials file not found: ${CREDS_FILE}" >&2 + echo "[deploy-api] Run setup-step10-creds.sh to create it." >&2 + exit 1 +fi + +if command -v jq &>/dev/null; then + DB_CONN="$(jq -r '.ConnectionStrings.DefaultConnection' "${CREDS_FILE}")" +else + # Fallback: extract with grep/sed if jq is not installed + DB_CONN="$(grep -o '"DefaultConnection"[[:space:]]*:[[:space:]]*"[^"]*"' "${CREDS_FILE}" \ + | sed 's/.*"DefaultConnection"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/')" +fi + +if [[ -z "${DB_CONN}" || "${DB_CONN}" == "null" ]]; then + echo "[deploy-api] ERROR: could not parse DefaultConnection from ${CREDS_FILE}" >&2 + exit 1 +fi + +echo "[deploy-api] connection string resolved from ${CREDS_FILE}" + +# ── Apply migrations (before touching the binary) ───────────────────────── +chmod +x "${BUNDLE}" +echo "[deploy-api] applying DeepDrftContext migrations via EF bundle" +"${BUNDLE}" --connection "${DB_CONN}" +rm -f "${BUNDLE}" +echo "[deploy-api] migrations done" + +# ── Swap in new binary tree ──────────────────────────────────────────────── +rm -rf "${APPROOT}/bin.prev" +if [[ -d "${APPROOT}/bin" ]]; then + mv "${APPROOT}/bin" "${APPROOT}/bin.prev" +fi +mkdir -p "${APPROOT}/bin" + +tar -xzf "${STAGING}/${ARCHIVE}" -C "${APPROOT}/bin" +rm -f "${STAGING}/${ARCHIVE}" + +echo "[deploy-api] archive extracted" + +# ── Apply environment files (host-managed, not in archive) ──────────────── +# ${APP_HOME}/api/deepdrft/environment/ may contain appsettings overrides. +# These must NOT live inside the rsync-writable staging area so a bad deploy +# cannot overwrite them. +if [[ -d "${APPROOT}/environment" ]]; then + shopt -s nullglob + env_files=("${APPROOT}/environment/"*) + shopt -u nullglob + if [[ ${#env_files[@]} -gt 0 ]]; then + mkdir -p "${APPROOT}/bin/environment" + cp "${env_files[@]}" "${APPROOT}/bin/environment/" + echo "[deploy-api] environment files applied" + fi +fi + +# ── Install systemd unit file (if present in staging) ───────────────────── +if [[ -f "${STAGING}/deepdrftapi.service" ]]; then + mkdir -p "${APP_HOME}/.config/systemd/user" + cp "${STAGING}/deepdrftapi.service" "${APP_HOME}/.config/systemd/user/deepdrftapi.service" + rm -f "${STAGING}/deepdrftapi.service" + systemctl --user daemon-reload + echo "[deploy-api] systemd unit file installed" +fi + +# ── Enable and restart service ───────────────────────────────────────────── +systemctl --user enable deepdrftapi.service +systemctl --user restart deepdrftapi.service +systemctl --user is-active --quiet deepdrftapi.service \ + && echo "[deploy-api] service is active" \ + || { echo "[deploy-api] ERROR: service failed to start" >&2; exit 1; } + +echo "[deploy-api] done" diff --git a/deploy/deploy-manager.sh b/deploy/deploy-manager.sh new file mode 100644 index 0000000..fdb05cd --- /dev/null +++ b/deploy/deploy-manager.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Installed to: /opt//bin/deploy-manager.sh +# Deployed by: deploy-manager Gitea Actions workflow (ssh forced-command) +# +# Expects in ${APP_HOME}/staging/: +# deepdrft-manager.tar.gz -- published self-contained linux-x64 binary tree +# +# DeepDrftManager receives its API URL + API key credential via systemd LoadCredential +# (api-manager.json -> $CREDENTIALS_DIRECTORY/api at runtime). No env file copy needed. +# +# Paths are derived at runtime — no hardcoded usernames or home dirs. +# APP_HOME comes from $HOME (sshd sets this for the app user). + +set -euo pipefail + +export XDG_RUNTIME_DIR="/run/user/$(id -u)" + +APP_HOME="${HOME}" +export PATH="${APP_HOME}/.local/bin:${PATH}" + +STAGING="${APP_HOME}/staging" +APPROOT="${APP_HOME}/manager" +ARCHIVE="deepdrft-manager.tar.gz" + +echo "[deploy-manager] $(date -u +%Y-%m-%dT%H:%M:%SZ) starting" + +# ── Swap in new binary tree ──────────────────────────────────────────────── +rm -rf "${APPROOT}/bin.prev" +if [[ -d "${APPROOT}/bin" ]]; then + mv "${APPROOT}/bin" "${APPROOT}/bin.prev" +fi +mkdir -p "${APPROOT}/bin" + +tar -xzf "${STAGING}/${ARCHIVE}" -C "${APPROOT}/bin" +rm -f "${STAGING}/${ARCHIVE}" + +echo "[deploy-manager] archive extracted" + +# ── Enable and restart service ───────────────────────────────────────────── +systemctl --user enable deepdrftmanager.service +systemctl --user restart deepdrftmanager.service +systemctl --user is-active --quiet deepdrftmanager.service \ + && echo "[deploy-manager] service is active" \ + || { echo "[deploy-manager] ERROR: service failed to start" >&2; exit 1; } + +echo "[deploy-manager] done" diff --git a/deploy/deploy-public.sh b/deploy/deploy-public.sh new file mode 100644 index 0000000..6ca8e21 --- /dev/null +++ b/deploy/deploy-public.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Installed to: /opt//bin/deploy-public.sh +# Deployed by: deploy-public Gitea Actions workflow (ssh forced-command) +# +# Expects in ${APP_HOME}/staging/: +# deepdrft-public.tar.gz -- published self-contained linux-x64 binary tree +# +# DeepDrftPublic receives its API URL credential via systemd LoadCredential +# (api-public.json -> $CREDENTIALS_DIRECTORY/api at runtime). No env file copy needed. +# +# Paths are derived at runtime — no hardcoded usernames or home dirs. +# APP_HOME comes from $HOME (sshd sets this for the app user). + +set -euo pipefail + +export XDG_RUNTIME_DIR="/run/user/$(id -u)" + +APP_HOME="${HOME}" +export PATH="${APP_HOME}/.local/bin:${PATH}" + +STAGING="${APP_HOME}/staging" +APPROOT="${APP_HOME}/public" +ARCHIVE="deepdrft-public.tar.gz" + +echo "[deploy-public] $(date -u +%Y-%m-%dT%H:%M:%SZ) starting" + +# ── Swap in new binary tree ──────────────────────────────────────────────── +rm -rf "${APPROOT}/bin.prev" +if [[ -d "${APPROOT}/bin" ]]; then + mv "${APPROOT}/bin" "${APPROOT}/bin.prev" +fi +mkdir -p "${APPROOT}/bin" + +tar -xzf "${STAGING}/${ARCHIVE}" -C "${APPROOT}/bin" +rm -f "${STAGING}/${ARCHIVE}" + +echo "[deploy-public] archive extracted" + +# ── Enable and restart service ───────────────────────────────────────────── +systemctl --user enable deepdrftpublic.service +systemctl --user restart deepdrftpublic.service +systemctl --user is-active --quiet deepdrftpublic.service \ + && echo "[deploy-public] service is active" \ + || { echo "[deploy-public] ERROR: service failed to start" >&2; exit 1; } + +echo "[deploy-public] done" diff --git a/deploy/install.sh b/deploy/install.sh new file mode 100644 index 0000000..176dbd1 --- /dev/null +++ b/deploy/install.sh @@ -0,0 +1,474 @@ +#!/usr/bin/env bash +# deploy/install.sh +# +# One-shot installer for the DeepDrft vertical on Linux. +# Runs from inside the extracted install tarball (or the deploy/ directory). +# Invoked directly or via bootstrap.sh: +# +# sudo bash install.sh +# +# All file references use ${SCRIPT_DIR}/... — no repo checkout required on the host. +# +# HOME=${APP_HOME} is load-bearing: the systemd %h specifier must resolve to the app +# home dir so that unit WorkingDirectory/ExecStart paths agree with the deploy scripts. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# ── Output helpers ───────────────────────────────────────────────────────────── +step() { echo; echo "=== Step $1: $2 ==="; } +info() { echo " [info] $*"; } +ok() { echo " [ok] $*"; } +die() { echo; echo " [ERROR] $*" >&2; exit 1; } + +# ── Step 0: Preflight ────────────────────────────────────────────────────────── +step 0 "Preflight" + +if [[ "${EUID}" -ne 0 ]]; then + die "Must run as root: sudo bash install.sh" +fi + +if ! command -v psql &>/dev/null; then + die "psql not found on PATH. Install PostgreSQL before running this script." +fi + +if ! pg_isready &>/dev/null; then + die "PostgreSQL is not accepting connections. Start it with: systemctl start postgresql" +fi + +if ! command -v nginx &>/dev/null; then + die "nginx not found on PATH. Install nginx before running this script." +fi + +# rrsync ships in the rsync package; path varies by distro version +RRSYNC_BIN="" +if command -v rrsync &>/dev/null; then + RRSYNC_BIN="$(command -v rrsync)" +elif [[ -x "/usr/lib/rsync/rrsync" ]]; then + RRSYNC_BIN="/usr/lib/rsync/rrsync" +else + die "rrsync not found. Install the rsync package: apt-get install -y rsync" +fi + +ok "psql present, PostgreSQL active, nginx present" +ok "rrsync found at: ${RRSYNC_BIN}" + +# ── Step 0b: Parameter collection ───────────────────────────────────────────── +step "0b" "Configuration parameters" + +echo +echo " Press Enter to accept the default shown in [brackets]." +echo + +read -rp " App system username [deepdrft]: " APP_USER +APP_USER="${APP_USER:-deepdrft}" + +read -rp " App home directory [/${APP_USER}]: " APP_HOME +APP_HOME="${APP_HOME:-/${APP_USER}}" + +read -rp " PostgreSQL role name [${APP_USER}]: " PG_ROLE +PG_ROLE="${PG_ROLE:-${APP_USER}}" + +read -rp " Metadata database name [deepdrft-meta]: " DB_META +DB_META="${DB_META:-deepdrft-meta}" + +read -rp " Auth database name [deepdrft-auth]: " DB_AUTH +DB_AUTH="${DB_AUTH:-deepdrft-auth}" + +read -rp " Public domain [deepdrft.com]: " DOMAIN_PUBLIC +DOMAIN_PUBLIC="${DOMAIN_PUBLIC:-deepdrft.com}" + +read -rp " App subdomain [app.${DOMAIN_PUBLIC}]: " DOMAIN_APP +DOMAIN_APP="${DOMAIN_APP:-app.${DOMAIN_PUBLIC}}" + +CERTBOT_EMAIL="" +while [[ -z "${CERTBOT_EMAIL}" ]]; do + read -rp " Email for certbot TLS cert (required): " CERTBOT_EMAIL +done + +# Derived paths +OPT_DIR="/opt/${APP_USER}/bin" +AK_FILE="${APP_HOME}/.ssh/authorized_keys" + +echo +echo " ┌──────────────────────────────────────────────────────────────┐" +echo " │ Installation settings │" +echo " ├──────────────────────────────────────────────────────────────┤" +printf " │ %-22s %-37s│\n" "APP_USER" "${APP_USER}" +printf " │ %-22s %-37s│\n" "APP_HOME" "${APP_HOME}" +printf " │ %-22s %-37s│\n" "PG_ROLE" "${PG_ROLE}" +printf " │ %-22s %-37s│\n" "DB_META" "${DB_META}" +printf " │ %-22s %-37s│\n" "DB_AUTH" "${DB_AUTH}" +printf " │ %-22s %-37s│\n" "DOMAIN_PUBLIC" "${DOMAIN_PUBLIC}" +printf " │ %-22s %-37s│\n" "DOMAIN_APP" "${DOMAIN_APP}" +printf " │ %-22s %-37s│\n" "CERTBOT_EMAIL" "${CERTBOT_EMAIL}" +printf " │ %-22s %-37s│\n" "OPT_DIR" "${OPT_DIR}" +echo " └──────────────────────────────────────────────────────────────┘" +echo + +read -rp " Proceed with these settings? [y/N] " CONFIRM +if [[ "${CONFIRM}" != "y" && "${CONFIRM}" != "Y" ]]; then + die "Aborted by user." +fi + +# ── Step 1: Create app system user ───────────────────────────────────────────── +step 1 "Create '${APP_USER}' system user" + +if id "${APP_USER}" &>/dev/null; then + ok "user '${APP_USER}' already exists — skipping" + if [[ ! -d "${APP_HOME}" ]]; then + info "Creating ${APP_HOME} (home dir missing)" + mkdir -p "${APP_HOME}" + fi + chown "${APP_USER}:${APP_USER}" "${APP_HOME}" + ok "${APP_HOME} owned by ${APP_USER}:${APP_USER}" +else + echo + echo " About to create system user '${APP_USER}' with home directory ${APP_HOME}" + echo " and shell /bin/bash (required for SSH forced-command dispatch)." + echo + read -rp " Proceed? [y/N] " CONFIRM_USER + if [[ "${CONFIRM_USER}" != "y" && "${CONFIRM_USER}" != "Y" ]]; then + die "Aborted by user." + fi + + useradd \ + --system \ + --home-dir "${APP_HOME}" \ + --create-home \ + --shell /bin/bash \ + "${APP_USER}" + + ok "User created:" + id "${APP_USER}" +fi + +# ── Step 2: Enable linger ────────────────────────────────────────────────────── +step 2 "Enable linger for '${APP_USER}'" + +if loginctl show-user "${APP_USER}" 2>/dev/null | grep -q "Linger=yes"; then + ok "linger already enabled" +else + loginctl enable-linger "${APP_USER}" + ok "linger enabled" +fi + +# ── Step 3: Directory layout ─────────────────────────────────────────────────── +step 3 "Directory layout" + +# Run as app user so files are created with correct ownership +sudo -u "${APP_USER}" mkdir -p "${APP_HOME}/public/bin" +sudo -u "${APP_USER}" mkdir -p "${APP_HOME}/public/environment" +sudo -u "${APP_USER}" mkdir -p "${APP_HOME}/manager/bin" +sudo -u "${APP_USER}" mkdir -p "${APP_HOME}/manager/environment" +sudo -u "${APP_USER}" mkdir -p "${APP_HOME}/api/deepdrft/bin" +sudo -u "${APP_USER}" mkdir -p "${APP_HOME}/api/deepdrft/environment" +# FileDatabase vault: persistent data, created once, never touched on deploy +sudo -u "${APP_USER}" mkdir -p "${APP_HOME}/api/deepdrft/vaults" +sudo -u "${APP_USER}" mkdir -p "${APP_HOME}/staging" +sudo -u "${APP_USER}" mkdir -p "${APP_HOME}/.ssh" +sudo -u "${APP_USER}" chmod 700 "${APP_HOME}/.ssh" + +ok "Directory layout ready" + +# ── Step 4: Deploy scripts ───────────────────────────────────────────────────── +step 4 "Deploy scripts -> ${OPT_DIR}/" + +mkdir -p "${OPT_DIR}" + +# Always overwrite — idempotent for re-runs that pick up script changes. +# ssh-wrapper is installed without .sh extension (authorized_keys points to it). +install -m 750 -o "${APP_USER}" -g "${APP_USER}" \ + "${SCRIPT_DIR}/ssh-wrapper.sh" "${OPT_DIR}/ssh-wrapper" +install -m 750 -o "${APP_USER}" -g "${APP_USER}" \ + "${SCRIPT_DIR}/deploy-public.sh" "${OPT_DIR}/deploy-public.sh" +install -m 750 -o "${APP_USER}" -g "${APP_USER}" \ + "${SCRIPT_DIR}/deploy-manager.sh" "${OPT_DIR}/deploy-manager.sh" +install -m 750 -o "${APP_USER}" -g "${APP_USER}" \ + "${SCRIPT_DIR}/deploy-api.sh" "${OPT_DIR}/deploy-api.sh" + +ok "Scripts installed:" +ls -la "${OPT_DIR}/" + +# ── Step 5: Systemd user units ───────────────────────────────────────────────── +step 5 "Systemd user units" + +APP_UID="$(id -u "${APP_USER}")" +APP_RUNTIME_DIR="/run/user/${APP_UID}" + +mkdir -p "${APP_HOME}/.config/systemd/user" + +# Always overwrite — idempotent for re-runs that pick up unit changes. +cp "${SCRIPT_DIR}/systemd/deepdrftpublic.service" "${APP_HOME}/.config/systemd/user/" +cp "${SCRIPT_DIR}/systemd/deepdrftmanager.service" "${APP_HOME}/.config/systemd/user/" +cp "${SCRIPT_DIR}/systemd/deepdrftapi.service" "${APP_HOME}/.config/systemd/user/" + +chown -R "${APP_USER}:${APP_USER}" "${APP_HOME}/.config/systemd" + +# daemon-reload and enable. XDG_RUNTIME_DIR must be set explicitly — PAM may not +# have set it for this non-interactive context, and it varies by distro. +sudo -u "${APP_USER}" env "XDG_RUNTIME_DIR=${APP_RUNTIME_DIR}" systemctl --user daemon-reload + +sudo -u "${APP_USER}" env "XDG_RUNTIME_DIR=${APP_RUNTIME_DIR}" systemctl --user enable \ + deepdrftpublic.service \ + deepdrftmanager.service \ + deepdrftapi.service + +ok "Units installed and enabled (not started — binaries don't exist yet)" + +# ── Step 6: Credentials ──────────────────────────────────────────────────────── +step 6 "Credentials" + +install -d -m 700 -o "${APP_USER}" -g "${APP_USER}" "${APP_HOME}/.config/credentials" + +CRED_COUNT="$(find "${APP_HOME}/.config/credentials/" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l)" + +if [[ "${CRED_COUNT}" -ge 6 ]]; then + ok "All credential files present (${CRED_COUNT} found) — skipping" + info "To refresh credentials, run: bash ${SCRIPT_DIR}/setup-step10-creds.sh" +else + info "${CRED_COUNT}/6 credential files present — running setup-step10-creds.sh" + sudo -u "${APP_USER}" \ + env APP_USER="${APP_USER}" \ + APP_HOME="${APP_HOME}" \ + PG_ROLE="${PG_ROLE}" \ + DB_META="${DB_META}" \ + DB_AUTH="${DB_AUTH}" \ + DOMAIN_PUBLIC="${DOMAIN_PUBLIC}" \ + DOMAIN_APP="${DOMAIN_APP}" \ + bash "${SCRIPT_DIR}/setup-step10-creds.sh" +fi + +# ── Step 7: PostgreSQL role and databases ────────────────────────────────────── +step 7 "PostgreSQL role and databases" + +echo +echo " The '${PG_ROLE}' PostgreSQL role needs a password for TCP connections." +echo " Local peer-auth connections (used by the deploy scripts) do not use it," +echo " but the credential JSON files reference it for app TCP connections." +echo + +read -rsp " PostgreSQL password for the '${PG_ROLE}' role: " PG_PASSWORD +echo +read -rsp " Confirm password: " PG_PASSWORD_CONFIRM +echo + +if [[ "${PG_PASSWORD}" != "${PG_PASSWORD_CONFIRM}" ]]; then + die "Passwords do not match." +fi + +if [[ -z "${PG_PASSWORD}" ]]; then + die "Password cannot be empty." +fi + +unset PG_PASSWORD_CONFIRM + +# Create role if it doesn't exist, then always set the password. +# Password is embedded in the heredoc body (psql stdin) — not in argv, +# so it does not appear in `ps aux`. PGPASSWORD env var is not used here +# because postgres peer auth (the default for the postgres superuser) does +# not require a connection password. +info "Creating/updating '${PG_ROLE}' PostgreSQL role..." + +sudo -u postgres psql < repo -> Settings -> Secrets -> DEEPDRFT_DCH7_SSH_DEPLOY" +echo " (or DEEPDRFT_PROD_SSH_DEPLOY for the production host)" +echo + +read -rp " Paste public key here: " PUBKEY + +# Strip carriage returns and leading/trailing whitespace (Windows clipboard, paste artifacts) +PUBKEY="$(printf '%s' "${PUBKEY}" | tr -d '\r' | xargs)" + +if [[ -z "${PUBKEY}" ]]; then + die "No public key provided." +fi + +# Validate it looks like an SSH public key +if [[ "${PUBKEY}" != ssh-ed25519* ]]; then + die "Key does not start with 'ssh-ed25519'. Only ed25519 keys are accepted for CI deploy." +fi + +if [[ -f "${AK_FILE}" ]] && grep -qF "${PUBKEY}" "${AK_FILE}"; then + ok "Key already present in authorized_keys — skipping" +else + # Write the forced-command + restrict prefix before the key. + # The 'restrict' keyword bundles no-port-forwarding, no-agent-forwarding, + # no-X11-forwarding, no-pty, no-user-rc into one short token. + AK_ENTRY="command=\"${OPT_DIR}/ssh-wrapper\",restrict ${PUBKEY}" + echo "${AK_ENTRY}" >> "${AK_FILE}" + ok "Key written to ${AK_FILE}" +fi + +chown "${APP_USER}:${APP_USER}" "${AK_FILE}" +chmod 600 "${AK_FILE}" + +unset PUBKEY + +# ── Step 9: nginx ────────────────────────────────────────────────────────────── +step 9 "nginx" + +# Read templates, substitute domain placeholders, write to sites-available. +# Templates use __DOMAIN_PUBLIC__ and __DOMAIN_APP__ so the files in the tarball +# don't contain real hostnames — substitution happens at install time. +sed -e "s|__DOMAIN_PUBLIC__|${DOMAIN_PUBLIC}|g" \ + "${SCRIPT_DIR}/nginx/deepdrft-public.conf" \ + > "/etc/nginx/sites-available/${DOMAIN_PUBLIC}.conf" + +sed -e "s|__DOMAIN_APP__|${DOMAIN_APP}|g" \ + "${SCRIPT_DIR}/nginx/deepdrft-manager.conf" \ + > "/etc/nginx/sites-available/${DOMAIN_APP}.conf" + +ln -sf "/etc/nginx/sites-available/${DOMAIN_PUBLIC}.conf" /etc/nginx/sites-enabled/ +ln -sf "/etc/nginx/sites-available/${DOMAIN_APP}.conf" /etc/nginx/sites-enabled/ + +rm -f /etc/nginx/sites-enabled/default + +if ! nginx -t; then + die "nginx config test failed — check ${SCRIPT_DIR}/nginx/*.conf" +fi + +systemctl reload nginx + +ok "nginx configured and reloaded" + +# ── Step 10: Summary ─────────────────────────────────────────────────────────── +step 10 "Summary" + +cat < repo -> Settings -> Secrets -> DEEPDRFT_DCH7_SSH_DEPLOY + (Value: full contents of ~/.ssh/gitea_${APP_USER}_dch7 on your local machine, + including -----BEGIN and -----END lines) + +2. Smoke-test the SSH forced-command chain from your LOCAL machine: + + Layer 1 — connectivity and dispatch: + ssh -i ~/.ssh/gitea_${APP_USER}_dch7 ${APP_USER}@ deploy-public + # Expect: "[deploy-public] ... starting" then error about missing archive + # (staging is empty at this stage). The exit non-zero is expected. + + Layer 2 — catch-all blocks arbitrary commands: + ssh -i ~/.ssh/gitea_${APP_USER}_dch7 ${APP_USER}@ id + # Expect: "ssh-wrapper: unknown command: id" — you must NOT get a shell. + + Layer 3 — rsync jail: + echo test > /tmp/smoke-test.txt + rsync -e "ssh -i ~/.ssh/gitea_${APP_USER}_dch7 -o StrictHostKeyChecking=yes" \\ + /tmp/smoke-test.txt ${APP_USER}@: + # Then verify on host: ls ${APP_HOME}/staging/smoke-test.txt + + Layer 4 — all three trigger words: + for cmd in deploy-public deploy-manager deploy-api; do + echo "--- \$cmd ---" + ssh -i ~/.ssh/gitea_${APP_USER}_dch7 ${APP_USER}@ "\$cmd" 2>&1 | head -5 + done + +3. TLS via certbot (run AFTER DNS is pointing at this host): + + snap install --classic certbot + ln -sf /snap/bin/certbot /usr/bin/certbot + + certbot --nginx \\ + --email ${CERTBOT_EMAIL} \\ + --agree-tos \\ + --no-eff-email \\ + -d ${DOMAIN_PUBLIC} \\ + -d ${DOMAIN_APP} + + nginx -t && systemctl reload nginx + certbot renew --dry-run + systemctl status snap.certbot.renew.timer + + Note: use the certbot SNAP (not apt) — the snap installs the correct + snap.certbot.renew.timer systemd unit. The apt package leaves auto-renewal + silently broken on Ubuntu. + +4. First deploy — push to Gitea master to trigger CI. + Services will start on first successful deploy. + +------------------------------------------------------------------------ +SUMMARY diff --git a/deploy/nginx/deepdrft-manager.conf b/deploy/nginx/deepdrft-manager.conf new file mode 100644 index 0000000..4274623 --- /dev/null +++ b/deploy/nginx/deepdrft-manager.conf @@ -0,0 +1,19 @@ +server { + listen 80; + listen [::]:80; + server_name __DOMAIN_APP__; + + location / { + proxy_pass http://localhost:5001; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket support (Blazor InteractiveServer SignalR circuits) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $http_connection; + } +} diff --git a/deploy/nginx/deepdrft-public.conf b/deploy/nginx/deepdrft-public.conf new file mode 100644 index 0000000..f4622a3 --- /dev/null +++ b/deploy/nginx/deepdrft-public.conf @@ -0,0 +1,19 @@ +server { + listen 80; + listen [::]:80; + server_name __DOMAIN_PUBLIC__; + + location / { + proxy_pass http://localhost:5000; + proxy_http_version 1.1; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket support (Blazor Server SignalR circuits + WASM interop) + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $http_connection; + } +} diff --git a/deploy/setup-step10-creds.sh b/deploy/setup-step10-creds.sh new file mode 100644 index 0000000..1b79d2b --- /dev/null +++ b/deploy/setup-step10-creds.sh @@ -0,0 +1,247 @@ +#!/usr/bin/env bash +# deploy/setup-step10-creds.sh +# Run as the app user on the host. Implements Step 6 of install.sh. +# Prompts interactively for every secret, writes JSON credential files +# directly to ${APP_HOME}/.config/credentials/. +# +# Called by install.sh (which exports APP_USER, APP_HOME, PG_ROLE, DB_META, +# DB_AUTH, DOMAIN_PUBLIC, DOMAIN_APP before invoking). Can also be run +# standalone — falls back to defaults so the script remains independently useful. +# +# Idempotency: safe to re-run. By default, credentials that already exist in +# ${CREDDIR} are left alone (no re-prompting). Pass --force as the first arg to +# re-prompt for every secret and overwrite all credential files. +# +# Credential files and their consumers: +# filedatabase.json -> DeepDrftAPI (LoadCredential id: filedatabase) +# apikey.json -> DeepDrftAPI (LoadCredential id: apikey) +# connections.json -> DeepDrftAPI (LoadCredential id: connections) +# authblocks.json -> DeepDrftAPI (LoadCredential id: authblocks) +# api-public.json -> DeepDrftPublic (LoadCredential id: api) +# api-manager.json -> DeepDrftManager (LoadCredential id: api) +# +# The LoadCredential ids (left of colon in the unit file) must exactly match +# CredentialTools.ResolvePathOrThrow keys in the application code. + +set -euo pipefail + +# Escape a string for safe use as inline JSON value. +# Escapes backslash then double-quote (the two characters that break JSON string literals). +# Does not handle embedded newlines or control chars — avoid those in secrets. +json_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' +} + +# Accept vars from environment (exported by install.sh) or fall back to defaults. +APP_USER="${APP_USER:-deepdrft}" +export XDG_RUNTIME_DIR="/run/user/$(id -u "${APP_USER}" 2>/dev/null || id -u)" +APP_HOME="${APP_HOME:-/${APP_USER}}" +PG_ROLE="${PG_ROLE:-deepdrft}" +DB_META="${DB_META:-deepdrft-meta}" +DB_AUTH="${DB_AUTH:-deepdrft-auth}" +DOMAIN_PUBLIC="${DOMAIN_PUBLIC:-deepdrft.com}" +DOMAIN_APP="${DOMAIN_APP:-app.${DOMAIN_PUBLIC}}" + +CREDDIR="${APP_HOME}/.config/credentials" +EXPECTED_COUNT=6 + +# ── Parse args ──────────────────────────────────────────────────────────────── +FORCE=0 +if [[ "${1:-}" == "--force" ]]; then + FORCE=1 + echo "[setup-step10-creds] --force: will re-prompt and overwrite all credentials" +fi + +# ── Credential directory ────────────────────────────────────────────────────── +if [[ ! -d "${CREDDIR}" ]]; then + mkdir -p "${CREDDIR}" + chmod 700 "${CREDDIR}" +fi +echo "[setup-step10-creds] ${CREDDIR} ready" + +# ── Helpers ─────────────────────────────────────────────────────────────────── +# write_cred +# Writes JSON content to ${CREDDIR}/.json (mode 600, owner APP_USER). +write_cred() { + local name="$1" + local content="$2" + local tmp + tmp="$(mktemp /tmp/deepdrft-cred-XXXXXXXX.json)" + printf '%s\n' "${content}" > "${tmp}" + install -m 600 -o "${APP_USER}" -g "${APP_USER}" "${tmp}" "${CREDDIR}/${name}.json" + rm -f "${tmp}" + echo "[setup-step10-creds] wrote ${name}.json" +} + +# need_cred +# Returns 0 (true) when the named credential should be (re)written: +# - --force was passed, OR +# - ${CREDDIR}/.json does not yet exist. +need_cred() { + local name="$1" + if [[ "${FORCE}" -eq 1 ]]; then + return 0 + fi + if [[ ! -f "${CREDDIR}/${name}.json" ]]; then + return 0 + fi + return 1 +} + +# ── 1. filedatabase.json — no prompts, path fully templated ────────────────── +if need_cred "filedatabase"; then + write_cred "filedatabase" \ + "{\"FileDatabaseSettings\":{\"VaultPath\":\"${APP_HOME}/api/deepdrft/vaults\"}}" +else + echo "[setup-step10-creds] filedatabase.json already exists, skipping" +fi + +# ── 2. apikey.json — prompt or generate; value reused in api-manager.json ──── +API_KEY="" +if need_cred "apikey"; then + echo + read -rp " API key (leave blank to generate with openssl rand -hex 32): " API_KEY_INPUT + if [[ -z "${API_KEY_INPUT}" ]]; then + API_KEY="$(openssl rand -hex 32)" + echo " [generated] API key: ${API_KEY}" + else + API_KEY="${API_KEY_INPUT}" + fi + unset API_KEY_INPUT + write_cred "apikey" \ + "{\"ApiKeySettings\":{\"ApiKey\":\"$(json_escape "${API_KEY}")\"}}" +else + echo "[setup-step10-creds] apikey.json already exists, skipping" + # Still need the value for api-manager.json if that's also being written. + # Read it back from the existing file if jq is available, otherwise prompt again. + if need_cred "api-manager"; then + if command -v jq &>/dev/null; then + API_KEY="$(jq -r '.ApiKeySettings.ApiKey' "${CREDDIR}/apikey.json")" + echo "[setup-step10-creds] API key read from existing apikey.json" + else + echo + echo " apikey.json exists but api-manager.json is missing." + read -rp " Re-enter the API key (needed for api-manager.json): " API_KEY + fi + fi +fi + +# ── 3. connections.json — prompt for PG password; DB names from env vars ───── +if need_cred "connections"; then + echo + echo " PostgreSQL connection strings for DeepDrftAPI." + echo " DB names: meta='${DB_META}', auth='${DB_AUTH}', role='${PG_ROLE}'" + echo + read -rsp " PostgreSQL password for the '${PG_ROLE}' role: " PG_PASSWORD + echo + read -rsp " Confirm password: " PG_PASSWORD_CONFIRM + echo + if [[ "${PG_PASSWORD}" != "${PG_PASSWORD_CONFIRM}" ]]; then + echo "[setup-step10-creds] ERROR: passwords do not match" >&2 + exit 1 + fi + if [[ -z "${PG_PASSWORD}" ]]; then + echo "[setup-step10-creds] ERROR: password cannot be empty" >&2 + exit 1 + fi + unset PG_PASSWORD_CONFIRM + + META_CONN="Host=localhost;Database=${DB_META};Username=${PG_ROLE};Password=$(json_escape "${PG_PASSWORD}")" + AUTH_CONN="Host=localhost;Database=${DB_AUTH};Username=${PG_ROLE};Password=$(json_escape "${PG_PASSWORD}")" + write_cred "connections" \ + "{\"ConnectionStrings\":{\"DefaultConnection\":\"${META_CONN}\",\"Auth\":\"${AUTH_CONN}\"}}" + unset PG_PASSWORD META_CONN AUTH_CONN +else + echo "[setup-step10-creds] connections.json already exists, skipping" +fi + +# ── 4. authblocks.json — prompt for all AuthBlocks secrets ─────────────────── +if need_cred "authblocks"; then + echo + echo " AuthBlocks configuration (JWT, email, admin account)." + echo + + # JWT secret + read -rp " JWT secret (leave blank to generate): " JWT_SECRET_INPUT + if [[ -z "${JWT_SECRET_INPUT}" ]]; then + JWT_SECRET="$(openssl rand -hex 32)" + echo " [generated] JWT secret" + else + JWT_SECRET="${JWT_SECRET_INPUT}" + fi + unset JWT_SECRET_INPUT + + # JWT issuer / audience + read -rp " JWT issuer (e.g. https://${DOMAIN_PUBLIC}): " JWT_ISSUER + read -rp " JWT audience (e.g. https://${DOMAIN_PUBLIC}): " JWT_AUDIENCE + + # Email + echo + echo " Email provider (SMTP/API — used by AuthBlocks for verification emails)." + read -rp " Email host (SMTP server or API host): " EMAIL_HOST + read -rsp " Email token (API key / SMTP password): " EMAIL_TOKEN + echo + + # Admin account + echo + echo " Initial admin account for DeepDrft." + read -rp " Admin username: " ADMIN_USERNAME + read -rp " Admin email: " ADMIN_EMAIL + read -rsp " Admin password: " ADMIN_PASSWORD + echo + read -rsp " Confirm admin password: " ADMIN_PASSWORD_CONFIRM + echo + if [[ "${ADMIN_PASSWORD}" != "${ADMIN_PASSWORD_CONFIRM}" ]]; then + echo "[setup-step10-creds] ERROR: admin passwords do not match" >&2 + exit 1 + fi + unset ADMIN_PASSWORD_CONFIRM + + # Support email + read -rp " Support email address: " SUPPORT_EMAIL + + write_cred "authblocks" "$(cat </dev/null || true +fi + +# ── Verify ──────────────────────────────────────────────────────────────────── +echo "" +echo "[setup-step10-creds] verifying ${CREDDIR}:" +ls -la "${CREDDIR}/" + +ACTUAL_COUNT="$(ls "${CREDDIR}/"*.json 2>/dev/null | wc -l)" +if [[ "${ACTUAL_COUNT}" -ne "${EXPECTED_COUNT}" ]]; then + echo "[setup-step10-creds] ERROR: expected ${EXPECTED_COUNT} .json files, found ${ACTUAL_COUNT}" >&2 + exit 1 +fi + +echo "[setup-step10-creds] all ${EXPECTED_COUNT} credentials present — step 6 complete" diff --git a/deploy/ssh-wrapper.sh b/deploy/ssh-wrapper.sh new file mode 100644 index 0000000..5072ff8 --- /dev/null +++ b/deploy/ssh-wrapper.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Installed to: /opt//bin/ssh-wrapper +# +# Forced-command wrapper for the CI deploy key. +# Install in ~/.ssh/authorized_keys as: +# +# command="/opt//bin/ssh-wrapper",restrict ssh-ed25519 AAAA... gitea-ci-deploy +# +# The 'restrict' keyword covers no-port-forwarding, no-agent-forwarding, +# no-X11-forwarding, no-pty, no-user-rc in one token. +# +# Supported commands dispatched by SSH_ORIGINAL_COMMAND: +# rsync --server ... -> rrsync jail (staging uploads) +# deploy-public -> /deploy-public.sh +# deploy-manager -> /deploy-manager.sh +# deploy-api -> /deploy-api.sh (no trailing arg — reads creds from host) +# +# Paths are derived at runtime — no hardcoded usernames or home dirs. +# APP_HOME comes from $HOME (sshd sets this for the app user). +# OPT_DIR is the directory containing this script. + +set -euo pipefail + +# Derive paths from runtime context — no hardcoded APP_USER or APP_HOME. +# sshd sets $HOME to the app user's home directory for forced-command sessions. +APP_HOME="${HOME}" +OPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +CMD="${SSH_ORIGINAL_COMMAND:-}" + +case "$CMD" in + "rsync --server"*) + exec rrsync "${APP_HOME}/staging" + ;; + deploy-public) + exec "${OPT_DIR}/deploy-public.sh" + ;; + deploy-manager) + exec "${OPT_DIR}/deploy-manager.sh" + ;; + deploy-api) + exec "${OPT_DIR}/deploy-api.sh" + ;; + *) + echo "ssh-wrapper: unknown command: ${CMD}" >&2 + exit 1 + ;; +esac diff --git a/deploy/systemd/deepdrftapi.service b/deploy/systemd/deepdrftapi.service new file mode 100644 index 0000000..199c0cd --- /dev/null +++ b/deploy/systemd/deepdrftapi.service @@ -0,0 +1,37 @@ +[Unit] +Description=DeepDrft API — dual-database authority (track metadata + FileDatabase + AuthBlocks) +After=network-online.target postgresql.service +Wants=network-online.target + +[Service] +Type=simple +Restart=always +RestartSec=5 + +WorkingDirectory=%h/api/deepdrft/bin +ExecStart=%h/api/deepdrft/bin/DeepDrftAPI + +# Non-secret config — hardcoded; no plaintext file needed. +Environment=ASPNETCORE_ENVIRONMENT=Production +Environment=ASPNETCORE_URLS=http://localhost:5002 + +# Secrets — loaded at startup into $CREDENTIALS_DIRECTORY/. +# Files live at %h/.config/credentials/ (deepdrft:deepdrft 600). +# +# LoadCredential ids (left of colon) MUST exactly match CredentialTools.ResolvePathOrThrow +# keys in the application code. Wrong id -> service throws on startup. +# filedatabase -> FileDatabaseSettings.VaultPath +# apikey -> ApiKeySettings.ApiKey +# connections -> ConnectionStrings.DefaultConnection + .Auth (two PG databases) +# authblocks -> AuthBlocks JWT / Email / Admin config +LoadCredential=filedatabase:%h/.config/credentials/filedatabase.json +LoadCredential=apikey:%h/.config/credentials/apikey.json +LoadCredential=connections:%h/.config/credentials/connections.json +LoadCredential=authblocks:%h/.config/credentials/authblocks.json + +StandardOutput=journal +StandardError=journal +SyslogIdentifier=deepdrftapi + +[Install] +WantedBy=default.target diff --git a/deploy/systemd/deepdrftmanager.service b/deploy/systemd/deepdrftmanager.service new file mode 100644 index 0000000..0a7ea87 --- /dev/null +++ b/deploy/systemd/deepdrftmanager.service @@ -0,0 +1,30 @@ +[Unit] +Description=DeepDrft Manager — Blazor InteractiveServer CMS +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +Restart=always +RestartSec=5 + +WorkingDirectory=%h/manager/bin +ExecStart=%h/manager/bin/DeepDrftManager + +# Non-secret config — hardcoded; no plaintext file needed. +Environment=ASPNETCORE_ENVIRONMENT=Production +Environment=ASPNETCORE_URLS=http://localhost:5001 + +# Secrets — loaded at startup into $CREDENTIALS_DIRECTORY/. +# File lives at %h/.config/credentials/ (deepdrft:deepdrft 600). +# LoadCredential id 'api' must match CredentialTools.ResolvePathOrThrow("api", ...) key. +LoadCredential=api:%h/.config/credentials/api-manager.json + +# Forward headers are configured in Program.cs for the nginx proxy topology. + +StandardOutput=journal +StandardError=journal +SyslogIdentifier=deepdrftmanager + +[Install] +WantedBy=default.target diff --git a/deploy/systemd/deepdrftpublic.service b/deploy/systemd/deepdrftpublic.service new file mode 100644 index 0000000..df9d42d --- /dev/null +++ b/deploy/systemd/deepdrftpublic.service @@ -0,0 +1,30 @@ +[Unit] +Description=DeepDrft Public Site — Blazor SSR + WASM public listener site +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +Restart=always +RestartSec=5 + +WorkingDirectory=%h/public/bin +ExecStart=%h/public/bin/DeepDrftPublic + +# Non-secret config — hardcoded; no plaintext file needed. +Environment=ASPNETCORE_ENVIRONMENT=Production +Environment=ASPNETCORE_URLS=http://localhost:5000 + +# Secrets — loaded at startup into $CREDENTIALS_DIRECTORY/. +# File lives at %h/.config/credentials/ (deepdrft:deepdrft 600). +# LoadCredential id 'api' must match CredentialTools.ResolvePathOrThrow("api", ...) key. +LoadCredential=api:%h/.config/credentials/api-public.json + +# Forward headers are configured in Program.cs for the nginx proxy topology. + +StandardOutput=journal +StandardError=journal +SyslogIdentifier=deepdrftpublic + +[Install] +WantedBy=default.target