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:
Claude 2026-03-24 15:06:34 +00:00
parent 92d37d9199
commit 48b9ccf152
No known key found for this signature in database
16 changed files with 402 additions and 27 deletions

84
Cargo.lock generated
View file

@ -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"

View file

@ -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 ─────────────────────────────────────────

View file

@ -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
View 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} ==="

View file

@ -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
View 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"

View file

@ -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"

View file

@ -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
View 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)
}

View file

@ -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,

View file

@ -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;

View file

@ -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)]

View file

@ -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)
} }

View file

@ -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) => {
let pw = crypto::decrypt(&d.pg_password)
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
Ok(Json(json!({
"app_id": d.app_id, "app_id": d.app_id,
"schema": d.app_id, "schema": d.app_id,
"pg_user": d.pg_user, "pg_user": d.pg_user,
"conn_str": conn_str(&d.pg_user, &d.pg_password), "conn_str": conn_str(&d.pg_user, &pw),
"created_at": d.created_at, "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()))?;

View file

@ -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)?;

View file

@ -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>