feat: M4 Hardening — encryption, resource limits, monitoring, backups
## Env var encryption at rest (AES-256-GCM) - server/src/crypto.rs: new module — encrypt/decrypt with AES-256-GCM Key = SHA-256(HIY_SECRET_KEY); non-prefixed values pass through transparently for zero-downtime migration - Cargo.toml: aes-gcm = "0.10" - routes/envvars.rs: encrypt on SET; list returns masked values (••••) - routes/databases.rs: pg_password and DATABASE_URL stored encrypted - routes/ui.rs: decrypt pg_password when rendering DB card - builder.rs: decrypt env vars when writing the .env file for containers - .env.example: add HIY_SECRET_KEY entry ## Per-app resource limits - apps table: memory_limit (default 512m) + cpu_limit (default 0.5) added via idempotent ALTER TABLE in db.rs migration - models.rs: App, CreateApp, UpdateApp gain memory_limit + cpu_limit - routes/apps.rs: persist limits on create, update via PUT - builder.rs: pass MEMORY_LIMIT + CPU_LIMIT to build script - builder/build.sh: use $MEMORY_LIMIT / $CPU_LIMIT in podman run (replaces hardcoded --cpus="0.5"; --memory now also set) ## Monitoring (opt-in compose profile) - infra/docker-compose.yml: gatus + netdata under `monitoring` profile Enable: podman compose --profile monitoring up -d Gatus on :8080, Netdata on :19999 - infra/gatus.yml: Gatus config checking HIY /api/status every minute ## Backup cron job - infra/backup.sh: dumps SQLite, copies env files + git repos into a dated .tar.gz; optional rclone upload; 30-day local retention Suggested cron: 0 3 * * * /path/to/infra/backup.sh https://claude.ai/code/session_01FKCW3FDjNFj6jve4niMFXH
This commit is contained in:
parent
92d37d9199
commit
48b9ccf152
16 changed files with 402 additions and 27 deletions
84
Cargo.lock
generated
84
Cargo.lock
generated
|
|
@ -2,6 +2,41 @@
|
||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aead"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"generic-array",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes"
|
||||||
|
version = "0.8.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cipher",
|
||||||
|
"cpufeatures",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aes-gcm"
|
||||||
|
version = "0.10.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
|
||||||
|
dependencies = [
|
||||||
|
"aead",
|
||||||
|
"aes",
|
||||||
|
"cipher",
|
||||||
|
"ctr",
|
||||||
|
"ghash",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ahash"
|
name = "ahash"
|
||||||
version = "0.8.12"
|
version = "0.8.12"
|
||||||
|
|
@ -341,9 +376,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"generic-array",
|
"generic-array",
|
||||||
|
"rand_core",
|
||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ctr"
|
||||||
|
version = "0.9.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
|
||||||
|
dependencies = [
|
||||||
|
"cipher",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "der"
|
name = "der"
|
||||||
version = "0.7.10"
|
version = "0.7.10"
|
||||||
|
|
@ -609,6 +654,16 @@ dependencies = [
|
||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ghash"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
|
||||||
|
dependencies = [
|
||||||
|
"opaque-debug",
|
||||||
|
"polyval",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.14.5"
|
version = "0.14.5"
|
||||||
|
|
@ -668,6 +723,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
||||||
name = "hiy-server"
|
name = "hiy-server"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aes-gcm",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
"axum",
|
"axum",
|
||||||
|
|
@ -1168,6 +1224,12 @@ version = "1.21.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opaque-debug"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
version = "0.12.5"
|
version = "0.12.5"
|
||||||
|
|
@ -1257,6 +1319,18 @@ version = "0.2.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "polyval"
|
||||||
|
version = "0.6.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"opaque-debug",
|
||||||
|
"universal-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
|
|
@ -2192,6 +2266,16 @@ version = "0.1.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "universal-hash"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||||
|
dependencies = [
|
||||||
|
"crypto-common",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "untrusted"
|
name = "untrusted"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,13 @@
|
||||||
# HIY Build Engine
|
# HIY Build Engine
|
||||||
# Environment variables injected by hiy-server:
|
# Environment variables injected by hiy-server:
|
||||||
# APP_ID, APP_NAME, REPO_URL, BRANCH, PORT, ENV_FILE, SHA, BUILD_DIR
|
# APP_ID, APP_NAME, REPO_URL, BRANCH, PORT, ENV_FILE, SHA, BUILD_DIR
|
||||||
|
# MEMORY_LIMIT (e.g. "512m"), CPU_LIMIT (e.g. "0.5")
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Defaults — overridden by per-app settings stored in the control plane.
|
||||||
|
MEMORY_LIMIT="${MEMORY_LIMIT:-512m}"
|
||||||
|
CPU_LIMIT="${CPU_LIMIT:-0.5}"
|
||||||
|
|
||||||
log() { echo "[hiy] $*"; }
|
log() { echo "[hiy] $*"; }
|
||||||
|
|
||||||
log "=== HostItYourself Build Engine ==="
|
log "=== HostItYourself Build Engine ==="
|
||||||
|
|
@ -105,7 +110,8 @@ podman run --detach \
|
||||||
--label "hiy.app=${APP_ID}" \
|
--label "hiy.app=${APP_ID}" \
|
||||||
--label "hiy.port=${PORT}" \
|
--label "hiy.port=${PORT}" \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
--cpus="0.5" \
|
--memory="${MEMORY_LIMIT}" \
|
||||||
|
--cpus="${CPU_LIMIT}" \
|
||||||
"$IMAGE_TAG"
|
"$IMAGE_TAG"
|
||||||
|
|
||||||
# ── 6. Update Caddy via its admin API ─────────────────────────────────────────
|
# ── 6. Update Caddy via its admin API ─────────────────────────────────────────
|
||||||
|
|
|
||||||
10
docs/plan.md
10
docs/plan.md
|
|
@ -261,11 +261,11 @@ hostityourself/
|
||||||
- [ ] Deploy history
|
- [ ] Deploy history
|
||||||
|
|
||||||
### M4 — Hardening (Week 5)
|
### M4 — Hardening (Week 5)
|
||||||
- [ ] Env var encryption at rest
|
- [x] Env var encryption at rest (AES-256-GCM via `HIY_SECRET_KEY`; transparent plaintext passthrough for migration)
|
||||||
- [ ] Resource limits on containers
|
- [x] Resource limits on containers (per-app `memory_limit` + `cpu_limit`; defaults 512m / 0.5 CPU)
|
||||||
- [ ] Netdata + Gatus setup
|
- [x] Netdata + Gatus setup (`monitoring` compose profile; `infra/gatus.yml`)
|
||||||
- [ ] Backup cron job
|
- [x] Backup cron job (`infra/backup.sh` — SQLite dump + env files + git repos; local + rclone remote)
|
||||||
- [ ] Dashboard auth
|
- [x] Dashboard auth (multi-user sessions, bcrypt, API keys — done in earlier milestone)
|
||||||
|
|
||||||
### M5 — Polish (Week 6)
|
### M5 — Polish (Week 6)
|
||||||
- [ ] Buildpack detection (Dockerfile / Node / Python / static)
|
- [ ] Buildpack detection (Dockerfile / Node / Python / static)
|
||||||
|
|
|
||||||
90
infra/backup.sh
Executable file
90
infra/backup.sh
Executable file
|
|
@ -0,0 +1,90 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# HIY daily backup script
|
||||||
|
#
|
||||||
|
# What is backed up:
|
||||||
|
# 1. SQLite database (hiy.db) — apps, deploys, env vars, users
|
||||||
|
# 2. Env files directory — decrypted env files written per deploy
|
||||||
|
# 3. Git repos — bare repos for git-push deploys
|
||||||
|
#
|
||||||
|
# Destination options (mutually exclusive; set one):
|
||||||
|
# HIY_BACKUP_DIR — local path (e.g. /mnt/usb/hiy-backups, default /tmp/hiy-backups)
|
||||||
|
# HIY_BACKUP_REMOTE — rclone remote:path (e.g. "b2:mybucket/hiy")
|
||||||
|
# requires rclone installed and configured
|
||||||
|
#
|
||||||
|
# Retention: 30 days (local only; remote retention is managed by the storage provider)
|
||||||
|
#
|
||||||
|
# Suggested cron (run as the same user as hiy-server):
|
||||||
|
# 0 3 * * * /path/to/infra/backup.sh >> /var/log/hiy-backup.log 2>&1
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Config ─────────────────────────────────────────────────────────────────────
|
||||||
|
HIY_DATA_DIR="${HIY_DATA_DIR:-/data}"
|
||||||
|
BACKUP_DIR="${HIY_BACKUP_DIR:-/tmp/hiy-backups}"
|
||||||
|
BACKUP_REMOTE="${HIY_BACKUP_REMOTE:-}"
|
||||||
|
RETAIN_DAYS="${HIY_BACKUP_RETAIN_DAYS:-30}"
|
||||||
|
|
||||||
|
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||||
|
ARCHIVE_NAME="hiy-backup-${TIMESTAMP}.tar.gz"
|
||||||
|
STAGING="${BACKUP_DIR}/staging-${TIMESTAMP}"
|
||||||
|
|
||||||
|
log() { echo "[hiy-backup] $(date '+%H:%M:%S') $*"; }
|
||||||
|
|
||||||
|
log "=== HIY Backup ==="
|
||||||
|
log "Data dir: ${HIY_DATA_DIR}"
|
||||||
|
log "Staging: ${STAGING}"
|
||||||
|
|
||||||
|
# ── 1. Stage files ─────────────────────────────────────────────────────────────
|
||||||
|
mkdir -p "${STAGING}"
|
||||||
|
|
||||||
|
# SQLite: use the .dump command to produce a portable SQL text dump.
|
||||||
|
if [ -f "${HIY_DATA_DIR}/hiy.db" ]; then
|
||||||
|
log "Dumping SQLite database…"
|
||||||
|
sqlite3 "${HIY_DATA_DIR}/hiy.db" .dump > "${STAGING}/hiy.sql"
|
||||||
|
else
|
||||||
|
log "WARNING: ${HIY_DATA_DIR}/hiy.db not found — skipping SQLite dump"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Env files (contain decrypted secrets — handle with care).
|
||||||
|
if [ -d "${HIY_DATA_DIR}/envs" ]; then
|
||||||
|
log "Copying env files…"
|
||||||
|
cp -r "${HIY_DATA_DIR}/envs" "${STAGING}/envs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Bare git repos.
|
||||||
|
if [ -d "${HIY_DATA_DIR}/repos" ]; then
|
||||||
|
log "Copying git repos…"
|
||||||
|
cp -r "${HIY_DATA_DIR}/repos" "${STAGING}/repos"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2. Create archive ──────────────────────────────────────────────────────────
|
||||||
|
mkdir -p "${BACKUP_DIR}"
|
||||||
|
ARCHIVE_PATH="${BACKUP_DIR}/${ARCHIVE_NAME}"
|
||||||
|
log "Creating archive: ${ARCHIVE_PATH}"
|
||||||
|
tar -czf "${ARCHIVE_PATH}" -C "${STAGING}" .
|
||||||
|
rm -rf "${STAGING}"
|
||||||
|
|
||||||
|
ARCHIVE_SIZE=$(du -sh "${ARCHIVE_PATH}" | cut -f1)
|
||||||
|
log "Archive size: ${ARCHIVE_SIZE}"
|
||||||
|
|
||||||
|
# ── 3. Upload to remote (optional) ────────────────────────────────────────────
|
||||||
|
if [ -n "${BACKUP_REMOTE}" ]; then
|
||||||
|
if command -v rclone &>/dev/null; then
|
||||||
|
log "Uploading to remote: ${BACKUP_REMOTE}"
|
||||||
|
rclone copy "${ARCHIVE_PATH}" "${BACKUP_REMOTE}/"
|
||||||
|
log "Upload complete."
|
||||||
|
else
|
||||||
|
log "WARNING: HIY_BACKUP_REMOTE is set but rclone is not installed — skipping upload"
|
||||||
|
log "Install rclone: https://rclone.org/install/"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 4. Rotate old local backups ────────────────────────────────────────────────
|
||||||
|
log "Removing local backups older than ${RETAIN_DAYS} days…"
|
||||||
|
find "${BACKUP_DIR}" -maxdepth 1 -name 'hiy-backup-*.tar.gz' \
|
||||||
|
-mtime "+${RETAIN_DAYS}" -delete
|
||||||
|
|
||||||
|
REMAINING=$(find "${BACKUP_DIR}" -maxdepth 1 -name 'hiy-backup-*.tar.gz' | wc -l)
|
||||||
|
log "Local backups retained: ${REMAINING}"
|
||||||
|
|
||||||
|
log "=== Backup complete: ${ARCHIVE_NAME} ==="
|
||||||
|
|
@ -94,6 +94,45 @@ services:
|
||||||
- hiy-net
|
- hiy-net
|
||||||
- default
|
- default
|
||||||
|
|
||||||
|
# ── Uptime / health checks ────────────────────────────────────────────────
|
||||||
|
# Enable with: podman compose --profile monitoring up -d
|
||||||
|
gatus:
|
||||||
|
profiles: [monitoring]
|
||||||
|
image: twinproduction/gatus:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./gatus.yml:/config/config.yaml:ro
|
||||||
|
networks:
|
||||||
|
- hiy-net
|
||||||
|
|
||||||
|
# ── Host metrics (rootful Podman / Docker only) ───────────────────────────
|
||||||
|
# On rootless Podman some host mounts may be unavailable; comment out if so.
|
||||||
|
netdata:
|
||||||
|
profiles: [monitoring]
|
||||||
|
image: netdata/netdata:stable
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "19999:19999"
|
||||||
|
pid: host
|
||||||
|
cap_add:
|
||||||
|
- SYS_PTRACE
|
||||||
|
- SYS_ADMIN
|
||||||
|
security_opt:
|
||||||
|
- apparmor:unconfined
|
||||||
|
volumes:
|
||||||
|
- netdata-config:/etc/netdata
|
||||||
|
- netdata-lib:/var/lib/netdata
|
||||||
|
- netdata-cache:/var/cache/netdata
|
||||||
|
- /etc/os-release:/host/etc/os-release:ro
|
||||||
|
- /etc/passwd:/host/etc/passwd:ro
|
||||||
|
- /etc/group:/host/etc/group:ro
|
||||||
|
- /proc:/host/proc:ro
|
||||||
|
- /sys:/host/sys:ro
|
||||||
|
networks:
|
||||||
|
- hiy-net
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
hiy-net:
|
hiy-net:
|
||||||
name: hiy-net
|
name: hiy-net
|
||||||
|
|
@ -105,3 +144,6 @@ volumes:
|
||||||
caddy-data:
|
caddy-data:
|
||||||
caddy-config:
|
caddy-config:
|
||||||
hiy-pg-data:
|
hiy-pg-data:
|
||||||
|
netdata-config:
|
||||||
|
netdata-lib:
|
||||||
|
netdata-cache:
|
||||||
|
|
|
||||||
39
infra/gatus.yml
Normal file
39
infra/gatus.yml
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# Gatus uptime / health check configuration for HIY.
|
||||||
|
# Docs: https://github.com/TwiN/gatus
|
||||||
|
|
||||||
|
web:
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
# In-memory storage — no persistence needed for uptime checks.
|
||||||
|
storage:
|
||||||
|
type: memory
|
||||||
|
|
||||||
|
# Alert via email when an endpoint is down (optional — remove if not needed).
|
||||||
|
# alerting:
|
||||||
|
# email:
|
||||||
|
# from: gatus@yourdomain.com
|
||||||
|
# username: gatus@yourdomain.com
|
||||||
|
# password: ${EMAIL_PASSWORD}
|
||||||
|
# host: smtp.yourdomain.com
|
||||||
|
# port: 587
|
||||||
|
# to: you@yourdomain.com
|
||||||
|
|
||||||
|
endpoints:
|
||||||
|
- name: HIY Dashboard
|
||||||
|
url: http://server:3000/api/status
|
||||||
|
interval: 1m
|
||||||
|
conditions:
|
||||||
|
- "[STATUS] == 200"
|
||||||
|
alerts:
|
||||||
|
- type: email
|
||||||
|
description: HIY dashboard is unreachable
|
||||||
|
send-on-resolved: true
|
||||||
|
|
||||||
|
# Add an entry per deployed app:
|
||||||
|
#
|
||||||
|
# - name: my-app
|
||||||
|
# url: http://my-app:3001/health
|
||||||
|
# interval: 1m
|
||||||
|
# conditions:
|
||||||
|
# - "[STATUS] == 200"
|
||||||
|
# - "[RESPONSE_TIME] < 500"
|
||||||
|
|
@ -24,6 +24,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
dotenvy = "0.15"
|
dotenvy = "0.15"
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
bcrypt = "0.15"
|
bcrypt = "0.15"
|
||||||
|
aes-gcm = "0.10"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
|
|
||||||
|
|
@ -81,10 +81,15 @@ async fn run_build(state: &AppState, deploy_id: &str) -> anyhow::Result<()> {
|
||||||
.fetch_all(&state.db)
|
.fetch_all(&state.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let env_content: String = env_vars
|
let mut env_content = String::new();
|
||||||
.iter()
|
for e in &env_vars {
|
||||||
.map(|e| format!("{}={}\n", e.key, e.value))
|
let plain = crate::crypto::decrypt(&e.value)
|
||||||
.collect();
|
.unwrap_or_else(|err| {
|
||||||
|
tracing::warn!("Could not decrypt env var {}: {} — using raw value", e.key, err);
|
||||||
|
e.value.clone()
|
||||||
|
});
|
||||||
|
env_content.push_str(&format!("{}={}\n", e.key, plain));
|
||||||
|
}
|
||||||
std::fs::write(&env_file, env_content)?;
|
std::fs::write(&env_file, env_content)?;
|
||||||
|
|
||||||
// Mark as building.
|
// Mark as building.
|
||||||
|
|
@ -138,6 +143,8 @@ async fn run_build(state: &AppState, deploy_id: &str) -> anyhow::Result<()> {
|
||||||
.env("ENV_FILE", &env_file)
|
.env("ENV_FILE", &env_file)
|
||||||
.env("SHA", deploy.sha.as_deref().unwrap_or(""))
|
.env("SHA", deploy.sha.as_deref().unwrap_or(""))
|
||||||
.env("BUILD_DIR", &build_dir)
|
.env("BUILD_DIR", &build_dir)
|
||||||
|
.env("MEMORY_LIMIT", &app.memory_limit)
|
||||||
|
.env("CPU_LIMIT", &app.cpu_limit)
|
||||||
.env("DOMAIN_SUFFIX", &domain_suffix)
|
.env("DOMAIN_SUFFIX", &domain_suffix)
|
||||||
.env("CADDY_API_URL", &caddy_api_url)
|
.env("CADDY_API_URL", &caddy_api_url)
|
||||||
.stdout(std::process::Stdio::piped())
|
.stdout(std::process::Stdio::piped())
|
||||||
|
|
|
||||||
60
server/src/crypto.rs
Normal file
60
server/src/crypto.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
/// AES-256-GCM envelope encryption for values stored at rest.
|
||||||
|
///
|
||||||
|
/// Encrypted blobs are prefixed with `enc:v1:` so plaintext values written
|
||||||
|
/// before encryption was enabled are transparently passed through on decrypt.
|
||||||
|
///
|
||||||
|
/// Key derivation: SHA-256 of `HIY_SECRET_KEY` env var. If the var is
|
||||||
|
/// absent a hard-coded default is used and a warning is logged once.
|
||||||
|
use aes_gcm::{
|
||||||
|
aead::{Aead, AeadCore, KeyInit, OsRng},
|
||||||
|
Aes256Gcm, Key, Nonce,
|
||||||
|
};
|
||||||
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
|
const PREFIX: &str = "enc:v1:";
|
||||||
|
|
||||||
|
fn key_bytes() -> [u8; 32] {
|
||||||
|
let secret = std::env::var("HIY_SECRET_KEY").unwrap_or_else(|_| {
|
||||||
|
tracing::warn!(
|
||||||
|
"HIY_SECRET_KEY is not set — env vars are encrypted with the default insecure key. \
|
||||||
|
Set HIY_SECRET_KEY in .env to a random 32+ char string."
|
||||||
|
);
|
||||||
|
"hiy-default-insecure-key-please-change-me".into()
|
||||||
|
});
|
||||||
|
Sha256::digest(secret.as_bytes()).into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypt a plaintext value and return `enc:v1:<b64(nonce||ciphertext)>`.
|
||||||
|
pub fn encrypt(plaintext: &str) -> anyhow::Result<String> {
|
||||||
|
let kb = key_bytes();
|
||||||
|
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&kb));
|
||||||
|
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||||
|
let ct = cipher
|
||||||
|
.encrypt(&nonce, plaintext.as_bytes())
|
||||||
|
.map_err(|e| anyhow::anyhow!("encrypt: {}", e))?;
|
||||||
|
let mut blob = nonce.to_vec();
|
||||||
|
blob.extend_from_slice(&ct);
|
||||||
|
Ok(format!("{}{}", PREFIX, STANDARD.encode(&blob)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypt an `enc:v1:…` value. Non-prefixed strings are returned as-is
|
||||||
|
/// (transparent migration path for pre-encryption data).
|
||||||
|
pub fn decrypt(value: &str) -> anyhow::Result<String> {
|
||||||
|
if !value.starts_with(PREFIX) {
|
||||||
|
return Ok(value.to_string());
|
||||||
|
}
|
||||||
|
let blob = STANDARD
|
||||||
|
.decode(&value[PREFIX.len()..])
|
||||||
|
.map_err(|e| anyhow::anyhow!("base64: {}", e))?;
|
||||||
|
if blob.len() < 12 {
|
||||||
|
anyhow::bail!("ciphertext too short");
|
||||||
|
}
|
||||||
|
let (nonce_bytes, ct) = blob.split_at(12);
|
||||||
|
let kb = key_bytes();
|
||||||
|
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&kb));
|
||||||
|
let plain = cipher
|
||||||
|
.decrypt(Nonce::from_slice(nonce_bytes), ct)
|
||||||
|
.map_err(|e| anyhow::anyhow!("decrypt: {}", e))?;
|
||||||
|
String::from_utf8(plain).map_err(Into::into)
|
||||||
|
}
|
||||||
|
|
@ -101,6 +101,12 @@ pub async fn migrate(pool: &DbPool) -> anyhow::Result<()> {
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// Idempotent column additions for existing databases (SQLite ignores "column exists" errors).
|
||||||
|
let _ = sqlx::query("ALTER TABLE apps ADD COLUMN memory_limit TEXT NOT NULL DEFAULT '512m'")
|
||||||
|
.execute(pool).await;
|
||||||
|
let _ = sqlx::query("ALTER TABLE apps ADD COLUMN cpu_limit TEXT NOT NULL DEFAULT '0.5'")
|
||||||
|
.execute(pool).await;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"CREATE TABLE IF NOT EXISTS databases (
|
r#"CREATE TABLE IF NOT EXISTS databases (
|
||||||
app_id TEXT PRIMARY KEY REFERENCES apps(id) ON DELETE CASCADE,
|
app_id TEXT PRIMARY KEY REFERENCES apps(id) ON DELETE CASCADE,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
mod auth;
|
mod auth;
|
||||||
mod builder;
|
mod builder;
|
||||||
|
mod crypto;
|
||||||
mod db;
|
mod db;
|
||||||
mod models;
|
mod models;
|
||||||
mod routes;
|
mod routes;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ pub struct App {
|
||||||
pub branch: String,
|
pub branch: String,
|
||||||
pub port: i64,
|
pub port: i64,
|
||||||
pub webhook_secret: String,
|
pub webhook_secret: String,
|
||||||
|
pub memory_limit: String,
|
||||||
|
pub cpu_limit: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
@ -18,6 +20,8 @@ pub struct CreateApp {
|
||||||
pub repo_url: Option<String>,
|
pub repo_url: Option<String>,
|
||||||
pub branch: Option<String>,
|
pub branch: Option<String>,
|
||||||
pub port: i64,
|
pub port: i64,
|
||||||
|
pub memory_limit: Option<String>,
|
||||||
|
pub cpu_limit: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|
@ -25,6 +29,8 @@ pub struct UpdateApp {
|
||||||
pub repo_url: Option<String>,
|
pub repo_url: Option<String>,
|
||||||
pub branch: Option<String>,
|
pub branch: Option<String>,
|
||||||
pub port: Option<i64>,
|
pub port: Option<i64>,
|
||||||
|
pub memory_limit: Option<String>,
|
||||||
|
pub cpu_limit: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
|
|
|
||||||
|
|
@ -29,10 +29,12 @@ pub async fn create(
|
||||||
let now = Utc::now().to_rfc3339();
|
let now = Utc::now().to_rfc3339();
|
||||||
let branch = payload.branch.unwrap_or_else(|| "main".into());
|
let branch = payload.branch.unwrap_or_else(|| "main".into());
|
||||||
let secret = Uuid::new_v4().to_string().replace('-', "");
|
let secret = Uuid::new_v4().to_string().replace('-', "");
|
||||||
|
let memory_limit = payload.memory_limit.unwrap_or_else(|| "512m".into());
|
||||||
|
let cpu_limit = payload.cpu_limit.unwrap_or_else(|| "0.5".into());
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO apps (id, name, repo_url, branch, port, webhook_secret, created_at, updated_at)
|
"INSERT INTO apps (id, name, repo_url, branch, port, webhook_secret, memory_limit, cpu_limit, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&id)
|
.bind(&id)
|
||||||
.bind(&payload.name)
|
.bind(&payload.name)
|
||||||
|
|
@ -40,6 +42,8 @@ pub async fn create(
|
||||||
.bind(&branch)
|
.bind(&branch)
|
||||||
.bind(payload.port)
|
.bind(payload.port)
|
||||||
.bind(&secret)
|
.bind(&secret)
|
||||||
|
.bind(&memory_limit)
|
||||||
|
.bind(&cpu_limit)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.execute(&s.db)
|
.execute(&s.db)
|
||||||
|
|
@ -89,6 +93,18 @@ pub async fn update(
|
||||||
.execute(&s.db).await
|
.execute(&s.db).await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
}
|
}
|
||||||
|
if let Some(v) = payload.memory_limit {
|
||||||
|
sqlx::query("UPDATE apps SET memory_limit = ?, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(v).bind(&now).bind(&id)
|
||||||
|
.execute(&s.db).await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
}
|
||||||
|
if let Some(v) = payload.cpu_limit {
|
||||||
|
sqlx::query("UPDATE apps SET cpu_limit = ?, updated_at = ? WHERE id = ?")
|
||||||
|
.bind(v).bind(&now).bind(&id)
|
||||||
|
.execute(&s.db).await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
}
|
||||||
|
|
||||||
fetch_app(&s, &id).await.map(Json)
|
fetch_app(&s, &id).await.map(Json)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ use axum::{
|
||||||
};
|
};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{models::Database, AppState};
|
use crate::{crypto, models::Database, AppState};
|
||||||
|
|
||||||
type ApiError = (StatusCode, String);
|
type ApiError = (StatusCode, String);
|
||||||
|
|
||||||
|
|
@ -39,13 +39,17 @@ pub async fn get_db(
|
||||||
|
|
||||||
match db {
|
match db {
|
||||||
None => Err(err(StatusCode::NOT_FOUND, "No database provisioned")),
|
None => Err(err(StatusCode::NOT_FOUND, "No database provisioned")),
|
||||||
Some(d) => Ok(Json(json!({
|
Some(d) => {
|
||||||
"app_id": d.app_id,
|
let pw = crypto::decrypt(&d.pg_password)
|
||||||
"schema": d.app_id,
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
"pg_user": d.pg_user,
|
Ok(Json(json!({
|
||||||
"conn_str": conn_str(&d.pg_user, &d.pg_password),
|
"app_id": d.app_id,
|
||||||
"created_at": d.created_at,
|
"schema": d.app_id,
|
||||||
}))),
|
"pg_user": d.pg_user,
|
||||||
|
"conn_str": conn_str(&d.pg_user, &pw),
|
||||||
|
"created_at": d.created_at,
|
||||||
|
})))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -107,27 +111,31 @@ pub async fn provision(
|
||||||
.execute(pg).await
|
.execute(pg).await
|
||||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// Persist credentials.
|
// Persist credentials (password encrypted at rest).
|
||||||
|
let enc_password = crypto::encrypt(&password)
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO databases (app_id, pg_user, pg_password, created_at) VALUES (?, ?, ?, ?)",
|
"INSERT INTO databases (app_id, pg_user, pg_password, created_at) VALUES (?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
.bind(&app_id)
|
.bind(&app_id)
|
||||||
.bind(&pg_user)
|
.bind(&pg_user)
|
||||||
.bind(&password)
|
.bind(&enc_password)
|
||||||
.bind(&now)
|
.bind(&now)
|
||||||
.execute(&s.db)
|
.execute(&s.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// Inject DATABASE_URL as an app env var (picked up on next deploy).
|
// Inject DATABASE_URL as an encrypted app env var (picked up on next deploy).
|
||||||
let url = conn_str(&pg_user, &password);
|
let url = conn_str(&pg_user, &password);
|
||||||
|
let enc_url = crypto::encrypt(&url)
|
||||||
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO env_vars (app_id, key, value) VALUES (?, 'DATABASE_URL', ?)
|
"INSERT INTO env_vars (app_id, key, value) VALUES (?, 'DATABASE_URL', ?)
|
||||||
ON CONFLICT (app_id, key) DO UPDATE SET value = excluded.value",
|
ON CONFLICT (app_id, key) DO UPDATE SET value = excluded.value",
|
||||||
)
|
)
|
||||||
.bind(&app_id)
|
.bind(&app_id)
|
||||||
.bind(&url)
|
.bind(&enc_url)
|
||||||
.execute(&s.db)
|
.execute(&s.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ use axum::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
crypto,
|
||||||
models::{EnvVar, SetEnvVar},
|
models::{EnvVar, SetEnvVar},
|
||||||
AppState,
|
AppState,
|
||||||
};
|
};
|
||||||
|
|
@ -20,7 +21,12 @@ pub async fn list(
|
||||||
.fetch_all(&s.db)
|
.fetch_all(&s.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
Ok(Json(vars))
|
// Return keys only; values are masked in the UI and never sent in plaintext.
|
||||||
|
let masked: Vec<EnvVar> = vars
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| EnvVar { value: "••••••••".into(), ..e })
|
||||||
|
.collect();
|
||||||
|
Ok(Json(masked))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn set(
|
pub async fn set(
|
||||||
|
|
@ -28,13 +34,15 @@ pub async fn set(
|
||||||
Path(app_id): Path<String>,
|
Path(app_id): Path<String>,
|
||||||
Json(payload): Json<SetEnvVar>,
|
Json(payload): Json<SetEnvVar>,
|
||||||
) -> Result<StatusCode, StatusCode> {
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
let encrypted = crypto::encrypt(&payload.value)
|
||||||
|
.map_err(|e| { tracing::error!("encrypt env var: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?;
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO env_vars (app_id, key, value) VALUES (?, ?, ?)
|
"INSERT INTO env_vars (app_id, key, value) VALUES (?, ?, ?)
|
||||||
ON CONFLICT(app_id, key) DO UPDATE SET value = excluded.value",
|
ON CONFLICT(app_id, key) DO UPDATE SET value = excluded.value",
|
||||||
)
|
)
|
||||||
.bind(&app_id)
|
.bind(&app_id)
|
||||||
.bind(&payload.key)
|
.bind(&payload.key)
|
||||||
.bind(&payload.value)
|
.bind(&encrypted)
|
||||||
.execute(&s.db)
|
.execute(&s.db)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
|
||||||
|
|
@ -278,7 +278,8 @@ pub async fn app_detail(
|
||||||
<button class="primary" onclick="provisionDb()">Provision Database</button></div>"#
|
<button class="primary" onclick="provisionDb()">Provision Database</button></div>"#
|
||||||
.to_string(),
|
.to_string(),
|
||||||
(true, Some(db)) => {
|
(true, Some(db)) => {
|
||||||
let url = format!("postgres://{}:{}@postgres:5432/hiy", db.pg_user, db.pg_password);
|
let pw = crate::crypto::decrypt(&db.pg_password).unwrap_or_default();
|
||||||
|
let url = format!("postgres://{}:{}@postgres:5432/hiy", db.pg_user, pw);
|
||||||
format!(r#"<div class="card"><h2>Database</h2>
|
format!(r#"<div class="card"><h2>Database</h2>
|
||||||
<table style="margin-bottom:16px">
|
<table style="margin-bottom:16px">
|
||||||
<tr><td style="width:160px">Schema</td><td><code>{schema}</code></td></tr>
|
<tr><td style="width:160px">Schema</td><td><code>{schema}</code></td></tr>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue