diff --git a/Cargo.lock b/Cargo.lock index 8cd2016..272c9c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,41 @@ # It is not intended for manual editing. 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]] name = "ahash" version = "0.8.12" @@ -259,6 +294,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -341,9 +382,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "der" version = "0.7.10" @@ -580,8 +631,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -591,9 +644,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -609,6 +664,16 @@ dependencies = [ "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]] name = "hashbrown" version = "0.14.5" @@ -668,6 +733,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" name = "hiy-server" version = "0.1.0" dependencies = [ + "aes-gcm", "anyhow", "async-stream", "axum", @@ -678,12 +744,13 @@ dependencies = [ "futures", "hex", "hmac", + "reqwest", "serde", "serde_json", "sha2", "sqlx", "tokio", - "tower-http", + "tower-http 0.5.2", "tracing", "tracing-subscriber", "uuid", @@ -780,6 +847,24 @@ dependencies = [ "pin-utils", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls 0.23.37", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.6", ] [[package]] @@ -788,13 +873,21 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64 0.22.1", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -950,6 +1043,22 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "itoa" version = "1.0.17" @@ -1043,6 +1152,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1127,7 +1242,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand", + "rand 0.8.5", "smallvec", "zeroize", ] @@ -1168,6 +1283,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1257,6 +1378,18 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "potential_utf" version = "0.1.4" @@ -1294,6 +1427,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.37", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls 0.23.37", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -1322,8 +1510,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "libc", - "rand_chacha", - "rand_core", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", ] [[package]] @@ -1333,7 +1531,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1345,6 +1553,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1380,6 +1597,44 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.37", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http 0.6.8", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.6", +] + [[package]] name = "ring" version = "0.17.14" @@ -1407,13 +1662,19 @@ dependencies = [ "num-traits", "pkcs1", "pkcs8", - "rand_core", + "rand_core 0.6.4", "signature", "spki", "subtle", "zeroize", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustix" version = "1.1.4" @@ -1434,10 +1695,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ "ring", - "rustls-webpki", + "rustls-webpki 0.101.7", "sct", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.10", + "subtle", + "zeroize", +] + [[package]] name = "rustls-pemfile" version = "1.0.4" @@ -1447,6 +1722,16 @@ dependencies = [ "base64 0.21.7", ] +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + [[package]] name = "rustls-webpki" version = "0.101.7" @@ -1457,6 +1742,17 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustls-webpki" +version = "0.103.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1611,7 +1907,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ "digest", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -1706,19 +2002,19 @@ dependencies = [ "once_cell", "paste", "percent-encoding", - "rustls", + "rustls 0.21.12", "rustls-pemfile", "serde", "serde_json", "sha2", "smallvec", "sqlformat", - "thiserror", + "thiserror 1.0.69", "tokio", "tokio-stream", "tracing", "url", - "webpki-roots", + "webpki-roots 0.25.4", ] [[package]] @@ -1790,7 +2086,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand", + "rand 0.8.5", "rsa", "serde", "sha1", @@ -1798,7 +2094,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.69", "tracing", "whoami", ] @@ -1830,14 +2126,14 @@ dependencies = [ "md-5", "memchr", "once_cell", - "rand", + "rand 0.8.5", "serde", "serde_json", "sha2", "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 1.0.69", "tracing", "whoami", ] @@ -1916,6 +2212,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -1947,7 +2246,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -1961,6 +2269,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -2023,6 +2342,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.37", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -2067,6 +2396,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -2141,6 +2488,12 @@ dependencies = [ "tracing-log", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.19.0" @@ -2192,6 +2545,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "untrusted" version = "0.9.0" @@ -2207,6 +2570,7 @@ dependencies = [ "form_urlencoded", "idna", "percent-encoding", + "serde", ] [[package]] @@ -2250,6 +2614,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2293,6 +2666,20 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.114" @@ -2359,12 +2746,41 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.25.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "whoami" version = "1.6.1" diff --git a/README.md b/README.md index 2023813..c654ca9 100644 --- a/README.md +++ b/README.md @@ -8,4 +8,5 @@ * Runs on your hardware (linux vm/host) * Integrate with git using github webhooks or add your own git remote * automatic redeployment after git push -* Builtin ssl. Automatically provisioned using let's encrypt. \ No newline at end of file +* Builtin ssl. Automatically provisioned using let's encrypt. +* Caddy reverse proxy \ No newline at end of file diff --git a/builder/build.sh b/builder/build.sh index 63389b5..b3696e1 100755 --- a/builder/build.sh +++ b/builder/build.sh @@ -2,8 +2,16 @@ # HIY Build Engine # Environment variables injected by hiy-server: # 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 +# Never prompt for git credentials — fail immediately if auth is missing. +export GIT_TERMINAL_PROMPT=0 + +# 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 "=== HostItYourself Build Engine ===" @@ -13,17 +21,33 @@ log "Branch: $BRANCH" log "Build dir: $BUILD_DIR" # ── 1. Clone or pull ─────────────────────────────────────────────────────────── +# Build an authenticated URL when a git token is set (private repos). +# GIT_TOKEN is passed by hiy-server and never echoed here. +CLONE_URL="$REPO_URL" +if [ -n "${GIT_TOKEN:-}" ]; then + case "$REPO_URL" in + https://*) + CLONE_URL="https://x-access-token:${GIT_TOKEN}@${REPO_URL#https://}" + ;; + esac +fi + mkdir -p "$BUILD_DIR" cd "$BUILD_DIR" if [ -d ".git" ]; then log "Updating existing clone…" + git remote set-url origin "$CLONE_URL" git fetch origin "$BRANCH" --depth=50 git checkout "$BRANCH" git reset --hard "origin/$BRANCH" + # Strip credentials from the stored remote so they don't sit in .git/config. + git remote set-url origin "$REPO_URL" else log "Cloning repository…" - git clone --depth=50 --branch "$BRANCH" "$REPO_URL" . + git clone --depth=50 --branch "$BRANCH" "$CLONE_URL" . + # Strip credentials from the stored remote so they don't sit in .git/config. + git remote set-url origin "$REPO_URL" fi ACTUAL_SHA=$(git rev-parse HEAD) @@ -105,7 +129,8 @@ podman run --detach \ --label "hiy.app=${APP_ID}" \ --label "hiy.port=${PORT}" \ --restart unless-stopped \ - --cpus="0.5" \ + --memory="${MEMORY_LIMIT}" \ + --cpus="${CPU_LIMIT}" \ "$IMAGE_TAG" # ── 6. Update Caddy via its admin API ───────────────────────────────────────── @@ -124,11 +149,20 @@ if curl --silent --fail "${CADDY_API}/config/" >/dev/null 2>&1; then else ROUTES_URL="${CADDY_API}/config/apps/http/servers/${CADDY_SERVER}/routes" - # Route JSON uses Caddy's forward_auth pattern: - # 1. HIY server checks the session cookie and app-level permission at /auth/verify - # 2. On 2xx → Caddy proxies to the app container - # 3. On anything else (e.g. 302 redirect to /login) → Caddy passes through to the client - ROUTE_JSON=$(python3 -c " + # Route JSON: public apps use plain reverse_proxy; private apps use forward_auth. + if [ "${IS_PUBLIC:-0}" = "1" ]; then + ROUTE_JSON=$(python3 -c " +import json, sys +upstream = sys.argv[1] +app_host = sys.argv[2] +route = { + 'match': [{'host': [app_host]}], + 'handle': [{'handler': 'reverse_proxy', 'upstreams': [{'dial': upstream}]}] +} +print(json.dumps(route)) +" "${UPSTREAM}" "${APP_ID}.${DOMAIN_SUFFIX}") + else + ROUTE_JSON=$(python3 -c " import json, sys upstream = sys.argv[1] app_host = sys.argv[2] @@ -162,6 +196,7 @@ route = { } print(json.dumps(route)) " "${UPSTREAM}" "${APP_ID}.${DOMAIN_SUFFIX}") + fi # Upsert the route for this app. ROUTES=$(curl --silent --fail "${ROUTES_URL}" 2>/dev/null || echo "[]") # Remove existing route for the same host, rebuild list, keep dashboard as catch-all. diff --git a/docs/plan.md b/docs/plan.md index f2fde51..31c1adf 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -261,11 +261,11 @@ hostityourself/ - [ ] Deploy history ### M4 — Hardening (Week 5) -- [ ] Env var encryption at rest -- [ ] Resource limits on containers -- [ ] Netdata + Gatus setup -- [ ] Backup cron job -- [ ] Dashboard auth +- [x] Env var encryption at rest (AES-256-GCM via `HIY_SECRET_KEY`; transparent plaintext passthrough for migration) +- [x] Resource limits on containers (per-app `memory_limit` + `cpu_limit`; defaults 512m / 0.5 CPU) +- [x] Netdata + Gatus setup (`monitoring` compose profile; `infra/gatus.yml`) +- [x] Backup cron job (`infra/backup.sh` — SQLite dump + env files + git repos; local + rclone remote) +- [x] Dashboard auth (multi-user sessions, bcrypt, API keys — done in earlier milestone) ### M5 — Polish (Week 6) - [ ] Buildpack detection (Dockerfile / Node / Python / static) diff --git a/docs/setup.md b/docs/setup.md index 6db01e5..9d9bbeb 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -45,7 +45,9 @@ ssh pi@hiypi.local ```bash sudo apt update && sudo apt full-upgrade -y -sudo apt install -y git curl ufw fail2ban unattended-upgrades +sudo apt install -y git curl ufw fail2ban unattended-upgrades podman python3 pipx aardvark-dns sqlite3 +pipx install podman-compose +pipx ensurepath ``` ### Static IP (optional but recommended) diff --git a/infra/.env.example b/infra/.env.example index 2001127..65b7b70 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -11,3 +11,19 @@ HIY_ADMIN_PASS=changeme # Postgres admin password — used by the shared cluster. # App schemas get their own scoped users; this password never leaves the server. POSTGRES_PASSWORD=changeme + +# Forgejo (optional — only needed if you add the forgejo service to docker-compose.yml). +FORGEJO_DB_PASSWORD=changeme +FORGEJO_DOMAIN=git.yourdomain.com +# Actions runner registration token — obtain from Forgejo: +# Site Administration → Actions → Runners → Create new runner +FORGEJO_RUNNER_TOKEN= + +# ── Backup (infra/backup.sh) ────────────────────────────────────────────────── +# Local directory to store backup archives. +HIY_BACKUP_DIR=/mnt/usb/hiy-backups +# Optional rclone remote (e.g. "b2:mybucket/hiy", "s3:mybucket/hiy"). +# Requires rclone installed and configured. Leave blank to skip remote upload. +HIY_BACKUP_REMOTE= +# How many days to keep local archives (default 30). +HIY_BACKUP_RETAIN_DAYS=30 diff --git a/infra/Dockerfile.server b/infra/Dockerfile.server index 13d8fdf..3b54f8a 100644 --- a/infra/Dockerfile.server +++ b/infra/Dockerfile.server @@ -1,66 +1,32 @@ # syntax=docker/dockerfile:1 # ── Build stage ─────────────────────────────────────────────────────────────── -# Run the compiler on the *build* host; cross-compile to target when needed. -FROM --platform=$BUILDPLATFORM rust:1.94-slim-bookworm AS builder +# Native build: Cargo targets the host platform automatically. +# No --target flag means no cross-compiler confusion regardless of which +# arch podman-compose runs on (x86_64, arm64, armv7…). +FROM docker.io/library/rust:1.94-slim-bookworm AS builder -ARG BUILDPLATFORM -ARG TARGETPLATFORM -ARG TARGETARCH -ARG TARGETVARIANT - -# Install cross-compilation toolchains only when actually cross-compiling. -RUN apt-get update && apt-get install -y pkg-config && \ - if [ "${BUILDPLATFORM}" != "${TARGETPLATFORM}" ]; then \ - case "${TARGETARCH}:${TARGETVARIANT}" in \ - "arm64:") apt-get install -y gcc-aarch64-linux-gnu ;; \ - "arm:v7") apt-get install -y gcc-arm-linux-gnueabihf ;; \ - "arm:v6") apt-get install -y gcc-arm-linux-gnueabi ;; \ - esac; \ - fi && \ +# gcc is required by cc-rs (used by aes-gcm / ring build scripts). +# rust:slim does not include a C compiler. +RUN apt-get update && apt-get install -y gcc pkg-config && \ rm -rf /var/lib/apt/lists/* -# Map TARGETARCH + TARGETVARIANT → Rust target triple, then install it. -RUN case "${TARGETARCH}:${TARGETVARIANT}" in \ - "amd64:") echo x86_64-unknown-linux-gnu ;; \ - "arm64:") echo aarch64-unknown-linux-gnu ;; \ - "arm:v7") echo armv7-unknown-linux-gnueabihf ;; \ - "arm:v6") echo arm-unknown-linux-gnueabi ;; \ - *) echo x86_64-unknown-linux-gnu ;; \ - esac > /rust_target && \ - rustup target add "$(cat /rust_target)" - -# Tell Cargo which cross-linker to use (ignored on native builds). -RUN mkdir -p /root/.cargo && printf '\ -[target.aarch64-unknown-linux-gnu]\n\ -linker = "aarch64-linux-gnu-gcc"\n\ -\n\ -[target.armv7-unknown-linux-gnueabihf]\n\ -linker = "arm-linux-gnueabihf-gcc"\n\ -\n\ -[target.arm-unknown-linux-gnueabi]\n\ -linker = "arm-linux-gnueabi-gcc"\n' >> /root/.cargo/config.toml - WORKDIR /build -# Cache dependencies separately from source. +# Cache dependency compilation separately from application source. COPY Cargo.toml Cargo.lock* ./ COPY server/Cargo.toml ./server/ RUN mkdir -p server/src && echo 'fn main(){}' > server/src/main.rs -RUN TARGET=$(cat /rust_target) && \ - cargo build --release --target "$TARGET" -p hiy-server 2>/dev/null || true +RUN cargo build --release -p hiy-server 2>/dev/null || true RUN rm -f server/src/main.rs # Build actual source. -COPY server/src ./server/src -RUN TARGET=$(cat /rust_target) && \ - touch server/src/main.rs && \ - cargo build --release --target "$TARGET" -p hiy-server - -# Normalise binary location so the runtime stage doesn't need to know the target. -RUN cp /build/target/"$(cat /rust_target)"/release/hiy-server /usr/local/bin/hiy-server +COPY server/src ./server/src +COPY server/templates ./server/templates +RUN touch server/src/main.rs && \ + cargo build --release -p hiy-server # ── Runtime stage ───────────────────────────────────────────────────────────── -FROM debian:bookworm-slim +FROM docker.io/library/debian:bookworm-slim RUN apt-get update && apt-get install -y \ ca-certificates \ @@ -71,7 +37,7 @@ RUN apt-get update && apt-get install -y \ podman \ && rm -rf /var/lib/apt/lists/* -COPY --from=builder /usr/local/bin/hiy-server /usr/local/bin/hiy-server +COPY --from=builder /build/target/release/hiy-server /usr/local/bin/hiy-server WORKDIR /app diff --git a/infra/auto-update.sh b/infra/auto-update.sh new file mode 100755 index 0000000..862dd31 --- /dev/null +++ b/infra/auto-update.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# auto-update.sh — pull latest changes and restart affected services. +# Run by the hiy-update.timer systemd user unit every 5 minutes. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +log() { echo "[hiy-update] $(date '+%H:%M:%S') $*"; } + +cd "$REPO_ROOT" + +# Fetch without touching the working tree. +git fetch origin 2>&1 | sed 's/^/[git] /' || { log "git fetch failed — skipping"; exit 0; } + +LOCAL=$(git rev-parse HEAD) +REMOTE=$(git rev-parse "@{u}" 2>/dev/null || echo "$LOCAL") + +if [ "$LOCAL" = "$REMOTE" ]; then + log "Already up to date ($LOCAL)." + exit 0 +fi + +log "New commits detected — pulling ($LOCAL → $REMOTE)…" +git pull 2>&1 | sed 's/^/[git] /' + +# Determine which services need restarting based on what changed. +CHANGED=$(git diff --name-only "$LOCAL" "$REMOTE") +log "Changed files: $(echo "$CHANGED" | tr '\n' ' ')" + +# Always rebuild the server if any server-side code changed. +SERVER_CHANGED=$(echo "$CHANGED" | grep -E '^server/|^Cargo' || true) +COMPOSE_CHANGED=$(echo "$CHANGED" | grep '^infra/docker-compose' || true) +CADDY_CHANGED=$(echo "$CHANGED" | grep '^proxy/Caddyfile' || true) + +COMPOSE_CMD="podman compose --env-file $REPO_ROOT/.env -f $SCRIPT_DIR/docker-compose.yml" + +if [ -n "$COMPOSE_CHANGED" ]; then + log "docker-compose.yml changed — restarting full stack…" + $COMPOSE_CMD up -d +elif [ -n "$SERVER_CHANGED" ]; then + log "Server code changed — rebuilding server…" + $COMPOSE_CMD up -d --build server +elif [ -n "$CADDY_CHANGED" ]; then + log "Caddyfile changed — reloading Caddy…" + $COMPOSE_CMD exec caddy caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile +else + log "No service restart needed for these changes." +fi + +log "Done." diff --git a/infra/backup.sh b/infra/backup.sh new file mode 100755 index 0000000..0d1e07c --- /dev/null +++ b/infra/backup.sh @@ -0,0 +1,162 @@ +#!/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 — per-deploy decrypted env files +# 3. Git repos — bare repos for git-push deploys +# 4. Postgres — pg_dumpall (hiy + forgejo databases) +# 5. Forgejo data volume — repositories, avatars, LFS objects +# 6. Caddy TLS certificates — caddy-data volume +# 7. .env file — secrets (handle the archive with care) +# +# 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 (remote retention managed by the storage provider). +# +# Suggested cron (run as the same user that owns the containers): +# 0 3 * * * /path/to/infra/backup.sh >> /var/log/hiy-backup.log 2>&1 + +set -euo pipefail + +# ── Load .env ────────────────────────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ENV_FILE="${SCRIPT_DIR}/../.env" +if [ -f "$ENV_FILE" ]; then + set -a; source "$ENV_FILE"; set +a +fi + +# ── 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}" + +mkdir -p "${STAGING}" + +# ── Helper: find a running container by compose service label ────────────────── +find_container() { + local service="$1" + podman ps --filter "label=com.docker.compose.service=${service}" \ + --format '{{.Names}}' | head -1 +} + +# ── 1. SQLite ────────────────────────────────────────────────────────────────── +log "--- SQLite ---" +SERVER_CTR=$(find_container server) +if [ -n "${SERVER_CTR}" ]; then + log "Copying hiy.db from container ${SERVER_CTR}…" + podman cp "${SERVER_CTR}:${HIY_DATA_DIR}/hiy.db" "${STAGING}/hiy.db" + log "Dumping hiy.db…" + sqlite3 "${STAGING}/hiy.db" .dump > "${STAGING}/hiy.sql" + rm "${STAGING}/hiy.db" +elif [ -f "${HIY_DATA_DIR}/hiy.db" ]; then + log "Server container not running — dumping from host path…" + sqlite3 "${HIY_DATA_DIR}/hiy.db" .dump > "${STAGING}/hiy.sql" +else + log "WARNING: hiy.db not found — skipping SQLite dump" +fi + +# ── 2. Env files ─────────────────────────────────────────────────────────────── +log "--- Env files ---" +if [ -n "${SERVER_CTR}" ]; then + podman exec "${SERVER_CTR}" sh -c \ + "[ -d ${HIY_DATA_DIR}/envs ] && tar -C ${HIY_DATA_DIR} -czf - envs" \ + > "${STAGING}/envs.tar.gz" 2>/dev/null || true +elif [ -d "${HIY_DATA_DIR}/envs" ]; then + tar -czf "${STAGING}/envs.tar.gz" -C "${HIY_DATA_DIR}" envs +fi + +# ── 3. Git repos ─────────────────────────────────────────────────────────────── +log "--- Git repos ---" +if [ -n "${SERVER_CTR}" ]; then + podman exec "${SERVER_CTR}" sh -c \ + "[ -d ${HIY_DATA_DIR}/repos ] && tar -C ${HIY_DATA_DIR} -czf - repos" \ + > "${STAGING}/repos.tar.gz" 2>/dev/null || true +elif [ -d "${HIY_DATA_DIR}/repos" ]; then + tar -czf "${STAGING}/repos.tar.gz" -C "${HIY_DATA_DIR}" repos +fi + +# ── 4. Postgres ──────────────────────────────────────────────────────────────── +log "--- Postgres ---" +PG_CTR=$(find_container postgres) +if [ -n "${PG_CTR}" ]; then + log "Running pg_dumpall via container ${PG_CTR}…" + podman exec "${PG_CTR}" pg_dumpall -U hiy_admin \ + > "${STAGING}/postgres.sql" +else + log "WARNING: postgres container not running — skipping Postgres dump" +fi + +# ── 5. Forgejo data volume ───────────────────────────────────────────────────── +log "--- Forgejo volume ---" +if podman volume exists forgejo-data 2>/dev/null; then + log "Exporting forgejo-data volume…" + podman volume export forgejo-data > "${STAGING}/forgejo-data.tar" +else + log "forgejo-data volume not found — skipping" +fi + +# ── 6. Caddy TLS certificates ────────────────────────────────────────────────── +log "--- Caddy volume ---" +if podman volume exists caddy-data 2>/dev/null; then + log "Exporting caddy-data volume…" + podman volume export caddy-data > "${STAGING}/caddy-data.tar" +else + log "caddy-data volume not found — skipping" +fi + +# ── 7. .env file ─────────────────────────────────────────────────────────────── +log "--- .env ---" +if [ -f "${ENV_FILE}" ]; then + cp "${ENV_FILE}" "${STAGING}/dot-env" + log "WARNING: archive contains plaintext secrets — store it securely" +else + log ".env not found at ${ENV_FILE} — skipping" +fi + +# ── 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}" + +# ── Upload to remote (optional) ──────────────────────────────────────────────── +if [ -n "${BACKUP_REMOTE}" ]; then + if command -v rclone &>/dev/null; then + log "Uploading to ${BACKUP_REMOTE}…" + #use patched rclone for now + /home/sander/dev/rclone/rclone copy --transfers 1 --retries 5 "${ARCHIVE_PATH}" "${BACKUP_REMOTE}/" + log "Upload complete." + else + log "WARNING: HIY_BACKUP_REMOTE is set but rclone is not installed — skipping" + log "Install: https://rclone.org/install/" + fi +fi + +# ── 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} ===" diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 2773344..d8d4376 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -12,7 +12,7 @@ services: # rootful: /run/podman/podman.sock # rootless: /run/user//podman/podman.sock (start.sh sets this) podman-proxy: - image: alpine/socat + image: docker.io/alpine/socat command: tcp-listen:2375,fork,reuseaddr unix-connect:/podman.sock restart: unless-stopped volumes: @@ -62,20 +62,71 @@ services: # ── Shared Postgres ─────────────────────────────────────────────────────── postgres: - image: postgres:16-alpine + image: docker.io/library/postgres:16-alpine restart: unless-stopped environment: POSTGRES_DB: hiy POSTGRES_USER: hiy_admin POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + FORGEJO_DB_PASSWORD: ${FORGEJO_DB_PASSWORD} volumes: - hiy-pg-data:/var/lib/postgresql/data + # SQL files here run once on first init (ignored if data volume already exists). + - ./postgres-init:/docker-entrypoint-initdb.d:ro + networks: + - hiy-net + + # ── Forgejo (self-hosted Git) ────────────────────────────────────────────── + forgejo: + image: codeberg.org/forgejo/forgejo:10 + restart: unless-stopped + environment: + USER_UID: 1000 + USER_GID: 1000 + FORGEJO__database__DB_TYPE: postgres + FORGEJO__database__HOST: postgres:5432 + FORGEJO__database__NAME: forgejo + FORGEJO__database__USER: forgejo + FORGEJO__database__PASSWD: ${FORGEJO_DB_PASSWORD} + FORGEJO__server__DOMAIN: ${FORGEJO_DOMAIN} + FORGEJO__server__ROOT_URL: https://${FORGEJO_DOMAIN}/ + FORGEJO__server__SSH_DOMAIN: ${FORGEJO_DOMAIN} + # Skip the first-run wizard — everything is configured via env vars above. + FORGEJO__security__INSTALL_LOCK: "true" + # Enable Actions. + FORGEJO__actions__ENABLED: "true" + volumes: + - forgejo-data:/data + depends_on: + - postgres + networks: + - hiy-net + + # ── Forgejo Actions runner ───────────────────────────────────────────────── + # Obtain FORGEJO_RUNNER_TOKEN from Forgejo: + # Site Administration → Actions → Runners → Create new runner + act_runner: + image: data.forgejo.org/forgejo/runner:6 + restart: unless-stopped + command: ["/entrypoint.sh"] + environment: + FORGEJO_INSTANCE_URL: http://forgejo:3000 + FORGEJO_RUNNER_TOKEN: ${FORGEJO_RUNNER_TOKEN} + FORGEJO_RUNNER_NAME: hiy-runner + # Give the runner access to Podman so CI jobs can build/run containers. + DOCKER_HOST: tcp://podman-proxy:2375 + volumes: + - act_runner_data:/data + - ./runner-entrypoint.sh:/entrypoint.sh:ro + depends_on: + - forgejo + - podman-proxy networks: - hiy-net # ── Reverse proxy ───────────────────────────────────────────────────────── caddy: - image: caddy:2-alpine + image: docker.io/library/caddy:2-alpine restart: unless-stopped ports: - "80:80" @@ -89,19 +140,64 @@ services: - ../proxy/Caddyfile:/etc/caddy/Caddyfile:ro - caddy-data:/data - caddy-config:/config - command: caddy run --config /etc/caddy/Caddyfile --adapter caddyfile --resume + command: caddy run --config /etc/caddy/Caddyfile --adapter caddyfile networks: - hiy-net - default + # ── Uptime / health checks ──────────────────────────────────────────────── + # Enable with: podman compose --profile monitoring up -d + gatus: + profiles: [monitoring] + image: docker.io/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: docker.io/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: hiy-net: name: hiy-net # External so deployed app containers can join it. external: false + default: {} volumes: hiy-data: + forgejo-data: + act_runner_data: caddy-data: caddy-config: hiy-pg-data: + netdata-config: + netdata-lib: + netdata-cache: diff --git a/infra/gatus.yml b/infra/gatus.yml new file mode 100644 index 0000000..00618c8 --- /dev/null +++ b/infra/gatus.yml @@ -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" diff --git a/infra/install.sh b/infra/install.sh new file mode 100755 index 0000000..5033afd --- /dev/null +++ b/infra/install.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# install.sh — one-time setup for a fresh Raspberry Pi. +# +# Run this once after cloning the repo: +# cd ~/Hostityourself && ./infra/install.sh +# +# What it does: +# 1. Installs system packages (podman, aardvark-dns, sqlite3, git, uidmap) +# 2. Installs podman-compose (via pip, into ~/.local/bin) +# 3. Installs rclone (for off-site backups — optional) +# 4. Creates .env from the template and prompts for required values +# 5. Runs infra/start.sh to build and launch the stack +# +# Safe to re-run — all steps are idempotent. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +log() { echo; echo "▶ $*"; } +info() { echo " $*"; } +ok() { echo " ✓ $*"; } + +echo "╔══════════════════════════════════════════╗" +echo "║ HostItYourself — installer ║" +echo "╚══════════════════════════════════════════╝" + +# ── 1. System packages ───────────────────────────────────────────────────────── +log "Installing system packages…" +sudo apt-get update -qq +sudo apt-get install -y \ + podman \ + aardvark-dns \ + sqlite3 \ + git \ + uidmap \ + python3-pip \ + python3-venv \ + curl +ok "System packages installed." + +# ── 2. podman-compose ────────────────────────────────────────────────────────── +log "Checking podman-compose…" +if command -v podman-compose &>/dev/null; then + ok "podman-compose already installed ($(podman-compose --version 2>&1 | head -1))." +else + info "Installing podman-compose via pip…" + pip3 install --user podman-compose + ok "podman-compose installed to ~/.local/bin" +fi + +# Ensure ~/.local/bin is in PATH for this session and future logins. +if ! echo "$PATH" | grep -q "$HOME/.local/bin"; then + export PATH="$HOME/.local/bin:$PATH" +fi +PROFILE="$HOME/.bashrc" +if ! grep -q '\.local/bin' "$PROFILE" 2>/dev/null; then + echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$PROFILE" + info "Added ~/.local/bin to PATH in $PROFILE" +fi + +# ── 3. rclone (optional) ─────────────────────────────────────────────────────── +log "rclone (used for off-site backups)…" +if command -v rclone &>/dev/null; then + ok "rclone already installed ($(rclone --version 2>&1 | head -1))." +else + read -r -p " Install rclone? [y/N] " _RCLONE + if [[ "${_RCLONE,,}" == "y" ]]; then + curl -fsSL https://rclone.org/install.sh | sudo bash + ok "rclone installed." + info "Configure a remote later with: rclone config" + info "Then set HIY_BACKUP_REMOTE in .env" + else + info "Skipped. Install later with: curl https://rclone.org/install.sh | sudo bash" + fi +fi + +# ── 4. .env setup ────────────────────────────────────────────────────────────── +log "Setting up .env…" +ENV_FILE="$REPO_ROOT/.env" +ENV_EXAMPLE="$SCRIPT_DIR/.env.example" + +if [ -f "$ENV_FILE" ]; then + ok ".env already exists — skipping (edit manually if needed)." +else + cp "$ENV_EXAMPLE" "$ENV_FILE" + info "Created .env from template. Filling in required values…" + echo + + # Helper: prompt for a value and write it into .env. + set_env() { + local key="$1" prompt="$2" default="$3" secret="${4:-}" + local current + current=$(grep "^${key}=" "$ENV_FILE" | cut -d= -f2- || echo "") + if [ -z "$current" ] || [ "$current" = "changeme" ] || [ "$current" = "" ]; then + if [ -n "$secret" ]; then + read -r -s -p " ${prompt} [${default}]: " _VAL; echo + else + read -r -p " ${prompt} [${default}]: " _VAL + fi + _VAL="${_VAL:-$default}" + # Replace the line in .env (works on both macOS and Linux). + sed -i "s|^${key}=.*|${key}=${_VAL}|" "$ENV_FILE" + fi + } + + set_env "DOMAIN_SUFFIX" "Your domain (e.g. example.com)" "yourdomain.com" + set_env "ACME_EMAIL" "Email for Let's Encrypt notices" "" + set_env "HIY_ADMIN_USER" "Dashboard admin username" "admin" + set_env "HIY_ADMIN_PASS" "Dashboard admin password" "$(openssl rand -hex 12)" "secret" + set_env "POSTGRES_PASSWORD" "Postgres admin password" "$(openssl rand -hex 16)" "secret" + set_env "FORGEJO_DB_PASSWORD" "Forgejo DB password" "$(openssl rand -hex 16)" "secret" + set_env "FORGEJO_DOMAIN" "Forgejo domain (e.g. git.example.com)" "git.yourdomain.com" + + echo + ok ".env written to $ENV_FILE" + info "Review it with: cat $ENV_FILE" +fi + +# ── 5. Git remote check ──────────────────────────────────────────────────────── +log "Checking git remote…" +cd "$REPO_ROOT" +REMOTE_URL=$(git remote get-url origin 2>/dev/null || echo "") +if [ -n "$REMOTE_URL" ]; then + ok "Git remote: $REMOTE_URL" +else + info "No git remote configured — auto-update will not work." + info "Set one with: git remote add origin " +fi + +# Ensure the tracking branch is set so auto-update.sh can compare commits. +BRANCH=$(git rev-parse --abbrev-ref HEAD) +if ! git rev-parse --abbrev-ref --symbolic-full-name '@{u}' &>/dev/null; then + info "Setting upstream tracking branch…" + git branch --set-upstream-to="origin/$BRANCH" "$BRANCH" 2>/dev/null || true +fi + +# ── 6. Launch the stack ──────────────────────────────────────────────────────── +log "Running start.sh to build and launch the stack…" +echo +exec "$SCRIPT_DIR/start.sh" diff --git a/infra/postgres-init/01-forgejo.sh b/infra/postgres-init/01-forgejo.sh new file mode 100755 index 0000000..7c7d7c3 --- /dev/null +++ b/infra/postgres-init/01-forgejo.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# Create a dedicated database and user for Forgejo. +# Runs once when the Postgres container is first initialised. +# FORGEJO_DB_PASSWORD must be set in the environment (via docker-compose.yml). +set -e + +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL + CREATE USER forgejo WITH PASSWORD '${FORGEJO_DB_PASSWORD}'; + CREATE DATABASE forgejo OWNER forgejo; +EOSQL diff --git a/infra/restore.sh b/infra/restore.sh new file mode 100755 index 0000000..4595a35 --- /dev/null +++ b/infra/restore.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# HIY restore script +# +# Restores a backup archive produced by infra/backup.sh. +# +# Usage: +# ./infra/restore.sh /path/to/hiy-backup-20260101-030000.tar.gz +# +# What is restored: +# 1. SQLite database (hiy.db) +# 2. Env files and git repos +# 3. Postgres databases (pg_dumpall dump) +# 4. Forgejo data volume +# 5. Caddy TLS certificates +# 6. .env file (optional — skipped if already present unless --force is passed) +# +# ⚠ Run this with the stack STOPPED, then bring it back up afterwards: +# podman compose -f infra/docker-compose.yml down +# ./infra/restore.sh hiy-backup-*.tar.gz +# podman compose -f infra/docker-compose.yml up -d + +set -euo pipefail + +ARCHIVE="${1:-}" +FORCE="${2:-}" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ENV_FILE="${SCRIPT_DIR}/../.env" +HIY_DATA_DIR="${HIY_DATA_DIR:-/data}" + +log() { echo "[hiy-restore] $(date '+%H:%M:%S') $*"; } +die() { log "ERROR: $*"; exit 1; } + +# ── Validate ─────────────────────────────────────────────────────────────────── +[ -z "${ARCHIVE}" ] && die "Usage: $0 [--force]" +[ -f "${ARCHIVE}" ] || die "Archive not found: ${ARCHIVE}" + +WORK_DIR=$(mktemp -d) +trap 'rm -rf "${WORK_DIR}"' EXIT + +log "=== HIY Restore ===" +log "Archive : ${ARCHIVE}" +log "Work dir: ${WORK_DIR}" + +log "Extracting archive…" +tar -xzf "${ARCHIVE}" -C "${WORK_DIR}" + +# ── Helper: find a running container by compose service label ────────────────── +find_container() { + local service="$1" + podman ps --filter "label=com.docker.compose.service=${service}" \ + --format '{{.Names}}' | head -1 +} + +# ── 1. .env file ─────────────────────────────────────────────────────────────── +log "--- .env ---" +if [ -f "${WORK_DIR}/dot-env" ]; then + if [ -f "${ENV_FILE}" ] && [ "${FORCE}" != "--force" ]; then + log "SKIP: ${ENV_FILE} already exists (pass --force to overwrite)" + else + cp "${WORK_DIR}/dot-env" "${ENV_FILE}" + log "Restored .env to ${ENV_FILE}" + fi +else + log "No .env in archive — skipping" +fi + +# ── 2. SQLite ────────────────────────────────────────────────────────────────── +log "--- SQLite ---" +if [ -f "${WORK_DIR}/hiy.sql" ]; then + DB_PATH="${HIY_DATA_DIR}/hiy.db" + mkdir -p "$(dirname "${DB_PATH}")" + if [ -f "${DB_PATH}" ]; then + log "Moving existing hiy.db to hiy.db.bak…" + mv "${DB_PATH}" "${DB_PATH}.bak" + fi + log "Restoring hiy.db…" + sqlite3 "${DB_PATH}" < "${WORK_DIR}/hiy.sql" + log "SQLite restored." +else + log "No hiy.sql in archive — skipping" +fi + +# ── 3. Env files & git repos ─────────────────────────────────────────────────── +log "--- Env files ---" +if [ -f "${WORK_DIR}/envs.tar.gz" ]; then + log "Restoring envs/…" + tar -xzf "${WORK_DIR}/envs.tar.gz" -C "${HIY_DATA_DIR}" +fi + +log "--- Git repos ---" +if [ -f "${WORK_DIR}/repos.tar.gz" ]; then + log "Restoring repos/…" + tar -xzf "${WORK_DIR}/repos.tar.gz" -C "${HIY_DATA_DIR}" +fi + +# ── 4. Postgres ──────────────────────────────────────────────────────────────── +log "--- Postgres ---" +if [ -f "${WORK_DIR}/postgres.sql" ]; then + PG_CTR=$(find_container postgres) + if [ -n "${PG_CTR}" ]; then + log "Restoring Postgres via container ${PG_CTR}…" + # Drop existing connections then restore. + podman exec -i "${PG_CTR}" psql -U hiy_admin -d postgres \ + -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname IN ('hiy','forgejo') AND pid <> pg_backend_pid();" \ + > /dev/null 2>&1 || true + podman exec -i "${PG_CTR}" psql -U hiy_admin -d postgres \ + < "${WORK_DIR}/postgres.sql" + log "Postgres restored." + else + log "WARNING: postgres container not running" + log " Start Postgres first, then run:" + log " podman exec -i psql -U hiy_admin -d postgres < ${WORK_DIR}/postgres.sql" + fi +else + log "No postgres.sql in archive — skipping" +fi + +# ── 5. Forgejo data volume ───────────────────────────────────────────────────── +log "--- Forgejo volume ---" +if [ -f "${WORK_DIR}/forgejo-data.tar" ]; then + log "Importing forgejo-data volume…" + podman volume exists forgejo-data 2>/dev/null || podman volume create forgejo-data + podman volume import forgejo-data "${WORK_DIR}/forgejo-data.tar" + log "forgejo-data restored." +else + log "No forgejo-data.tar in archive — skipping" +fi + +# ── 6. Caddy TLS certificates ────────────────────────────────────────────────── +log "--- Caddy volume ---" +if [ -f "${WORK_DIR}/caddy-data.tar" ]; then + log "Importing caddy-data volume…" + podman volume exists caddy-data 2>/dev/null || podman volume create caddy-data + podman volume import caddy-data "${WORK_DIR}/caddy-data.tar" + log "caddy-data restored." +else + log "No caddy-data.tar in archive — skipping" +fi + +log "=== Restore complete ===" +log "Bring the stack back up with:" +log " podman compose -f ${SCRIPT_DIR}/docker-compose.yml up -d" diff --git a/infra/runner-entrypoint.sh b/infra/runner-entrypoint.sh new file mode 100755 index 0000000..8ddee42 --- /dev/null +++ b/infra/runner-entrypoint.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# runner-entrypoint.sh — register the Forgejo runner on first start, then run the daemon. +# +# On first run (no /data/.runner file) it calls create-runner-file to register +# with the Forgejo instance using FORGEJO_RUNNER_TOKEN. On subsequent starts it +# goes straight to the daemon. +set -e + +CONFIG=/data/.runner + +if [ ! -f "$CONFIG" ]; then + echo "[runner] No registration found — registering with Forgejo…" + forgejo-runner register \ + --instance "${FORGEJO_INSTANCE_URL}" \ + --token "${FORGEJO_RUNNER_TOKEN}" \ + --name "${FORGEJO_RUNNER_NAME:-hiy-runner}" \ + --labels "ubuntu-latest:docker://node:20-bookworm,ubuntu-22.04:docker://node:20-bookworm" \ + --no-interactive + echo "[runner] Registration complete." +fi + +echo "[runner] Starting daemon…" +exec forgejo-runner daemon --config "$CONFIG" diff --git a/infra/start.sh b/infra/start.sh index 1d453bc..67c84ea 100755 --- a/infra/start.sh +++ b/infra/start.sh @@ -20,45 +20,10 @@ if [ -z "$DOMAIN_SUFFIX" ] || [ "$DOMAIN_SUFFIX" = "localhost" ]; then fi if [ -z "$ACME_EMAIL" ]; then - echo "ERROR: Set ACME_EMAIL in infra/.env (required for Let's Encrypt)" - exit 1 + echo "[hiy] ACME_EMAIL not set — Caddy will use its internal CA (self-signed)." + echo "[hiy] For a public domain with Let's Encrypt, set ACME_EMAIL in infra/.env" fi -# ── Generate production caddy.json ───────────────────────────────────────────── -# Writes TLS-enabled config using Let's Encrypt (no Cloudflare required). -# Caddy will use the HTTP-01 challenge (port 80) or TLS-ALPN-01 (port 443). -cat > "$SCRIPT_DIR/../proxy/caddy.json" </dev/null || true echo "[hiy] Boot service installed: systemctl --user status hiy.service" + +# ── Install systemd timer for auto-update ───────────────────────────────────── +UPDATE_SERVICE="$SERVICE_DIR/hiy-update.service" +UPDATE_TIMER="$SERVICE_DIR/hiy-update.timer" + +cat > "$UPDATE_SERVICE" < "$UPDATE_TIMER" < anyhow::Result<()> { .fetch_all(&state.db) .await?; - let env_content: String = env_vars - .iter() - .map(|e| format!("{}={}\n", e.key, e.value)) - .collect(); + let mut env_content = String::new(); + for e in &env_vars { + let plain = crate::crypto::decrypt(&e.value) + .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)?; // Mark as building. @@ -128,16 +133,30 @@ async fn run_build(state: &AppState, deploy_id: &str) -> anyhow::Result<()> { let domain_suffix = std::env::var("DOMAIN_SUFFIX").unwrap_or_else(|_| "localhost".into()); let caddy_api_url = std::env::var("CADDY_API_URL").unwrap_or_else(|_| "http://localhost:2019".into()); - let mut child = Command::new("bash") - .arg(&build_script) + let mut cmd = Command::new("bash"); + cmd.arg(&build_script) .env("APP_ID", &app.id) .env("APP_NAME", &app.name) - .env("REPO_URL", &repo_url) + .env("REPO_URL", &repo_url); + + // Decrypt the git token (if any) and pass it separately so build.sh can + // inject it into the clone URL without it appearing in REPO_URL or logs. + if let Some(enc) = &app.git_token { + match crate::crypto::decrypt(enc) { + Ok(tok) => { cmd.env("GIT_TOKEN", tok); } + Err(e) => tracing::warn!("Could not decrypt git_token for {}: {}", app.id, e), + } + } + + let mut child = cmd .env("BRANCH", &app.branch) .env("PORT", app.port.to_string()) .env("ENV_FILE", &env_file) .env("SHA", deploy.sha.as_deref().unwrap_or("")) .env("BUILD_DIR", &build_dir) + .env("MEMORY_LIMIT", &app.memory_limit) + .env("CPU_LIMIT", &app.cpu_limit) + .env("IS_PUBLIC", if app.is_public != 0 { "1" } else { "0" }) .env("DOMAIN_SUFFIX", &domain_suffix) .env("CADDY_API_URL", &caddy_api_url) .stdout(std::process::Stdio::piped()) diff --git a/server/src/crypto.rs b/server/src/crypto.rs new file mode 100644 index 0000000..12d0f2e --- /dev/null +++ b/server/src/crypto.rs @@ -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:`. +pub fn encrypt(plaintext: &str) -> anyhow::Result { + let kb = key_bytes(); + let cipher = Aes256Gcm::new(Key::::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 { + 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::::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) +} diff --git a/server/src/db.rs b/server/src/db.rs index a2eebe1..fb34d02 100644 --- a/server/src/db.rs +++ b/server/src/db.rs @@ -101,6 +101,16 @@ pub async fn migrate(pool: &DbPool) -> anyhow::Result<()> { .execute(pool) .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; + let _ = sqlx::query("ALTER TABLE apps ADD COLUMN git_token TEXT") + .execute(pool).await; + let _ = sqlx::query("ALTER TABLE apps ADD COLUMN is_public INTEGER NOT NULL DEFAULT 0") + .execute(pool).await; + sqlx::query( r#"CREATE TABLE IF NOT EXISTS databases ( app_id TEXT PRIMARY KEY REFERENCES apps(id) ON DELETE CASCADE, diff --git a/server/src/main.rs b/server/src/main.rs index 4235a8e..6bb8821 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -10,6 +10,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; mod auth; mod builder; +mod crypto; mod db; mod models; mod routes; @@ -153,6 +154,20 @@ async fn main() -> anyhow::Result<()> { builder::build_worker(worker_state).await; }); + // Re-register all app Caddy routes from the DB on startup. + // Caddy no longer uses --resume, so routes must be restored each time the + // stack restarts (ensures Caddyfile changes are always picked up). + let restore_db = state.db.clone(); + tokio::spawn(async move { + routes::apps::restore_caddy_routes(&restore_db).await; + }); + + // Restart any app containers that are stopped (e.g. after a host reboot). + let containers_db = state.db.clone(); + tokio::spawn(async move { + routes::apps::restore_app_containers(&containers_db).await; + }); + // ── Protected routes (admin login required) ─────────────────────────────── let protected = Router::new() .route("/", get(routes::ui::index)) @@ -163,6 +178,7 @@ async fn main() -> anyhow::Result<()> { .route("/api/apps", get(routes::apps::list).post(routes::apps::create)) .route("/api/apps/:id", get(routes::apps::get_one) .put(routes::apps::update) + .patch(routes::apps::update) .delete(routes::apps::delete)) .route("/api/apps/:id/stop", post(routes::apps::stop)) .route("/api/apps/:id/restart", post(routes::apps::restart)) diff --git a/server/src/models.rs b/server/src/models.rs index a2444a6..994f5a4 100644 --- a/server/src/models.rs +++ b/server/src/models.rs @@ -8,8 +8,14 @@ pub struct App { pub branch: String, pub port: i64, pub webhook_secret: String, + pub memory_limit: String, + pub cpu_limit: String, pub created_at: String, pub updated_at: String, + /// Encrypted git token for cloning private repos. Never serialised to API responses. + #[serde(skip_serializing)] + pub git_token: Option, + pub is_public: i64, } #[derive(Debug, Deserialize)] @@ -18,6 +24,9 @@ pub struct CreateApp { pub repo_url: Option, pub branch: Option, pub port: i64, + pub memory_limit: Option, + pub cpu_limit: Option, + pub git_token: Option, } #[derive(Debug, Deserialize)] @@ -25,6 +34,10 @@ pub struct UpdateApp { pub repo_url: Option, pub branch: Option, pub port: Option, + pub memory_limit: Option, + pub cpu_limit: Option, + pub git_token: Option, + pub is_public: Option, } #[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] diff --git a/server/src/routes/apps.rs b/server/src/routes/apps.rs index e5344a5..869ca4f 100644 --- a/server/src/routes/apps.rs +++ b/server/src/routes/apps.rs @@ -12,6 +12,187 @@ use crate::{ AppState, }; +/// Build the Caddy route JSON for an app. +/// Public apps get a plain reverse_proxy; private apps get forward_auth via HIY. +fn caddy_route(app_host: &str, upstream: &str, is_public: bool) -> serde_json::Value { + if is_public { + serde_json::json!({ + "match": [{"host": [app_host]}], + "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": upstream}]}] + }) + } else { + serde_json::json!({ + "match": [{"host": [app_host]}], + "handle": [{ + "handler": "subroute", + "routes": [{ + "handle": [{ + "handler": "reverse_proxy", + "rewrite": {"method": "GET", "uri": "/auth/verify"}, + "headers": {"request": {"set": { + "X-Forwarded-Method": ["{http.request.method}"], + "X-Forwarded-Uri": ["{http.request.uri}"], + "X-Forwarded-Host": ["{http.request.host}"], + "X-Forwarded-Proto": ["{http.request.scheme}"] + }}}, + "upstreams": [{"dial": "server:3000"}], + "handle_response": [{ + "match": {"status_code": [2]}, + "routes": [{"handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": upstream}]}]}] + }] + }] + }] + }] + }) + } +} + +/// Re-register every app's Caddy route from the database. +/// Called at startup so that removing `--resume` from Caddy doesn't lose +/// routes when the stack restarts. +pub async fn restore_caddy_routes(db: &crate::DbPool) { + // Give Caddy a moment to finish loading the Caddyfile before we PATCH it. + let caddy_api = std::env::var("CADDY_API_URL").unwrap_or_else(|_| "http://caddy:2019".into()); + let client = reqwest::Client::new(); + for attempt in 1..=10u32 { + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + if client.get(format!("{}/config/", caddy_api)).send().await.is_ok() { + break; + } + tracing::info!("restore_caddy_routes: waiting for Caddy ({}/10)…", attempt); + } + + let apps = match sqlx::query_as::<_, crate::models::App>("SELECT * FROM apps") + .fetch_all(db) + .await + { + Ok(a) => a, + Err(e) => { tracing::error!("restore_caddy_routes: DB error: {}", e); return; } + }; + + for app in &apps { + push_visibility_to_caddy(&app.id, app.port, app.is_public != 0).await; + } + tracing::info!("restore_caddy_routes: registered {} app routes", apps.len()); +} + +/// On startup, ensure every app that had a successful deploy is actually running. +/// If the host rebooted, containers will be in "exited" state — start them. +/// If a container is missing entirely, log a warning (we don't rebuild automatically). +pub async fn restore_app_containers(db: &crate::DbPool) { + let apps = match sqlx::query_as::<_, crate::models::App>("SELECT * FROM apps") + .fetch_all(db) + .await + { + Ok(a) => a, + Err(e) => { tracing::error!("restore_app_containers: DB error: {}", e); return; } + }; + + for app in &apps { + // Only care about apps that have at least one successful deploy. + let has_deploy: bool = sqlx::query_scalar( + "SELECT COUNT(*) > 0 FROM deploys WHERE app_id = ? AND status = 'success'" + ) + .bind(&app.id) + .fetch_one(db) + .await + .unwrap_or(false); + + if !has_deploy { + continue; + } + + let container = format!("hiy-{}", app.id); + + // Check container state via `podman inspect`. + let inspect = tokio::process::Command::new("podman") + .args(["inspect", "--format", "{{.State.Status}}", &container]) + .output() + .await; + + match inspect { + Ok(out) if out.status.success() => { + let status = String::from_utf8_lossy(&out.stdout).trim().to_string(); + if status == "running" { + tracing::debug!("restore_app_containers: {} already running", container); + } else { + tracing::info!("restore_app_containers: starting {} (was {})", container, status); + let start = tokio::process::Command::new("podman") + .args(["start", &container]) + .output() + .await; + match start { + Ok(o) if o.status.success() => + tracing::info!("restore_app_containers: {} started", container), + Ok(o) => + tracing::warn!("restore_app_containers: failed to start {}: {}", + container, String::from_utf8_lossy(&o.stderr).trim()), + Err(e) => + tracing::warn!("restore_app_containers: error starting {}: {}", container, e), + } + } + } + _ => { + tracing::warn!( + "restore_app_containers: container {} not found — redeploy needed", + container + ); + } + } + } + tracing::info!("restore_app_containers: done"); +} + +/// Push a visibility change to Caddy without requiring a full redeploy. +/// Best-effort: logs a warning on failure but does not surface an error to the caller. +async fn push_visibility_to_caddy(app_id: &str, port: i64, is_public: bool) { + if let Err(e) = try_push_visibility_to_caddy(app_id, port, is_public).await { + tracing::warn!("caddy visibility update for {}: {}", app_id, e); + } +} + +async fn try_push_visibility_to_caddy(app_id: &str, port: i64, is_public: bool) -> anyhow::Result<()> { + let caddy_api = std::env::var("CADDY_API_URL").unwrap_or_else(|_| "http://caddy:2019".into()); + let domain = std::env::var("DOMAIN_SUFFIX").unwrap_or_else(|_| "localhost".into()); + let app_host = format!("{}.{}", app_id, domain); + let upstream = format!("hiy-{}:{}", app_id, port); + let client = reqwest::Client::new(); + + // Discover the Caddy server name (Caddyfile adapter names it "srv0"). + let servers: serde_json::Value = client + .get(format!("{}/config/apps/http/servers/", caddy_api)) + .send().await? + .json().await?; + let server_name = servers.as_object() + .and_then(|m| m.keys().next().cloned()) + .ok_or_else(|| anyhow::anyhow!("no servers in Caddy config"))?; + + let routes_url = format!("{}/config/apps/http/servers/{}/routes", caddy_api, server_name); + + let routes: Vec = client.get(&routes_url).send().await?.json().await?; + + let dashboard = serde_json::json!({ + "handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "server:3000"}]}] + }); + + let mut updated: Vec = routes.into_iter() + .filter(|r| { + let is_this_app = r.pointer("/match/0/host") + .and_then(|h| h.as_array()) + .map(|hosts| hosts.iter().any(|h| h.as_str() == Some(app_host.as_str()))) + .unwrap_or(false); + let is_catchall = r.get("match").is_none(); + !is_this_app && !is_catchall + }) + .collect(); + + updated.insert(0, caddy_route(&app_host, &upstream, is_public)); + updated.push(dashboard); + + client.patch(&routes_url).json(&updated).send().await?; + Ok(()) +} + pub async fn list(State(s): State) -> Result>, StatusCode> { let apps = sqlx::query_as::<_, App>("SELECT * FROM apps ORDER BY created_at DESC") .fetch_all(&s.db) @@ -29,10 +210,18 @@ pub async fn create( let now = Utc::now().to_rfc3339(); let branch = payload.branch.unwrap_or_else(|| "main".into()); 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()); + let git_token_enc = payload.git_token + .as_deref() + .filter(|t| !t.is_empty()) + .map(crate::crypto::encrypt) + .transpose() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; sqlx::query( - "INSERT INTO apps (id, name, repo_url, branch, port, webhook_secret, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO apps (id, name, repo_url, branch, port, webhook_secret, memory_limit, cpu_limit, git_token, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(&id) .bind(&payload.name) @@ -40,6 +229,9 @@ pub async fn create( .bind(&branch) .bind(payload.port) .bind(&secret) + .bind(&memory_limit) + .bind(&cpu_limit) + .bind(&git_token_enc) .bind(&now) .bind(&now) .execute(&s.db) @@ -89,6 +281,44 @@ pub async fn update( .execute(&s.db).await .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)?; + } + if let Some(v) = payload.is_public { + let flag: i64 = if v { 1 } else { 0 }; + sqlx::query("UPDATE apps SET is_public = ?, updated_at = ? WHERE id = ?") + .bind(flag).bind(&now).bind(&id) + .execute(&s.db).await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + // Immediately reconfigure the Caddy route so the change takes effect + // without a full redeploy. + let app = fetch_app(&s, &id).await?; + push_visibility_to_caddy(&id, app.port, v).await; + } + if let Some(v) = payload.git_token { + if v.is_empty() { + sqlx::query("UPDATE apps SET git_token = NULL, updated_at = ? WHERE id = ?") + .bind(&now).bind(&id) + .execute(&s.db).await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } else { + let enc = crate::crypto::encrypt(&v) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + sqlx::query("UPDATE apps SET git_token = ?, updated_at = ? WHERE id = ?") + .bind(enc).bind(&now).bind(&id) + .execute(&s.db).await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + } + } fetch_app(&s, &id).await.map(Json) } diff --git a/server/src/routes/databases.rs b/server/src/routes/databases.rs index 37db709..33f6fbc 100644 --- a/server/src/routes/databases.rs +++ b/server/src/routes/databases.rs @@ -5,7 +5,7 @@ use axum::{ }; use serde_json::json; -use crate::{models::Database, AppState}; +use crate::{crypto, models::Database, AppState}; type ApiError = (StatusCode, String); @@ -39,13 +39,17 @@ pub async fn get_db( match db { None => Err(err(StatusCode::NOT_FOUND, "No database provisioned")), - Some(d) => Ok(Json(json!({ - "app_id": d.app_id, - "schema": d.app_id, - "pg_user": d.pg_user, - "conn_str": conn_str(&d.pg_user, &d.pg_password), - "created_at": d.created_at, - }))), + 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, + "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 .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(); sqlx::query( "INSERT INTO databases (app_id, pg_user, pg_password, created_at) VALUES (?, ?, ?, ?)", ) .bind(&app_id) .bind(&pg_user) - .bind(&password) + .bind(&enc_password) .bind(&now) .execute(&s.db) .await .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 enc_url = crypto::encrypt(&url) + .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; sqlx::query( "INSERT INTO env_vars (app_id, key, value) VALUES (?, 'DATABASE_URL', ?) ON CONFLICT (app_id, key) DO UPDATE SET value = excluded.value", ) .bind(&app_id) - .bind(&url) + .bind(&enc_url) .execute(&s.db) .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; diff --git a/server/src/routes/envvars.rs b/server/src/routes/envvars.rs index 4ef00fd..6ae8f0a 100644 --- a/server/src/routes/envvars.rs +++ b/server/src/routes/envvars.rs @@ -5,6 +5,7 @@ use axum::{ }; use crate::{ + crypto, models::{EnvVar, SetEnvVar}, AppState, }; @@ -20,7 +21,12 @@ pub async fn list( .fetch_all(&s.db) .await .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 = vars + .into_iter() + .map(|e| EnvVar { value: "••••••••".into(), ..e }) + .collect(); + Ok(Json(masked)) } pub async fn set( @@ -28,13 +34,15 @@ pub async fn set( Path(app_id): Path, Json(payload): Json, ) -> Result { + let encrypted = crypto::encrypt(&payload.value) + .map_err(|e| { tracing::error!("encrypt env var: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; sqlx::query( "INSERT INTO env_vars (app_id, key, value) VALUES (?, ?, ?) ON CONFLICT(app_id, key) DO UPDATE SET value = excluded.value", ) .bind(&app_id) .bind(&payload.key) - .bind(&payload.value) + .bind(&encrypted) .execute(&s.db) .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; diff --git a/server/src/routes/ui.rs b/server/src/routes/ui.rs index 875e13a..fb1bb23 100644 --- a/server/src/routes/ui.rs +++ b/server/src/routes/ui.rs @@ -278,7 +278,8 @@ pub async fn app_detail( "# .to_string(), (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#"

Database

@@ -296,18 +297,45 @@ pub async fn app_detail( } }; + let is_public = app.is_public != 0; + let visibility_badge = if is_public { + r#"public"# + } else { + r#"private"# + }; + let visibility_toggle_label = if is_public { "Make private" } else { "Make public" }; + + let (git_token_status, git_token_clear_btn) = if app.git_token.is_some() { + ( + r#"Token configured"#.to_string(), + r#""#.to_string(), + ) + } else { + ( + r#"No token — public repos only"#.to_string(), + String::new(), + ) + }; + let body = APP_DETAIL_TMPL - .replace("{{name}}", &app.name) - .replace("{{repo}}", &app.repo_url) - .replace("{{branch}}", &app.branch) - .replace("{{port}}", &app.port.to_string()) - .replace("{{host}}", &host) - .replace("{{app_id}}", &app.id) - .replace("{{secret}}", &app.webhook_secret) - .replace("{{deploy_rows}}", &deploy_rows) - .replace("{{env_rows}}", &env_rows) - .replace("{{c_badge}}", &container_badge(&container_state)) - .replace("{{db_card}}", &db_card); + .replace("{{name}}", &app.name) + .replace("{{repo}}", &app.repo_url) + .replace("{{branch}}", &app.branch) + .replace("{{port}}", &app.port.to_string()) + .replace("{{host}}", &host) + .replace("{{app_id}}", &app.id) + .replace("{{secret}}", &app.webhook_secret) + .replace("{{memory_limit}}", &app.memory_limit) + .replace("{{cpu_limit}}", &app.cpu_limit) + .replace("{{deploy_rows}}", &deploy_rows) + .replace("{{env_rows}}", &env_rows) + .replace("{{c_badge}}", &container_badge(&container_state)) + .replace("{{db_card}}", &db_card) + .replace("{{git_token_status}}", &git_token_status) + .replace("{{git_token_clear_btn}}", &git_token_clear_btn) + .replace("{{visibility_badge}}", visibility_badge) + .replace("{{visibility_toggle_label}}", visibility_toggle_label) + .replace("{{is_public_js}}", if is_public { "true" } else { "false" }); Html(page(&app.name, &body)).into_response() } diff --git a/server/templates/app_detail.html b/server/templates/app_detail.html index f071f89..3ba7b00 100644 --- a/server/templates/app_detail.html +++ b/server/templates/app_detail.html @@ -14,6 +14,8 @@  ·  branch {{branch}}  ·  port {{port}}  ·  {{name}}.{{host}} +  ·  {{visibility_badge}} +

@@ -33,8 +35,42 @@
+
+

Settings

+
+
+
+
+
+
+
+
+
+ +
+ {{db_card}} +
+

Git Authentication

+

+ Required for private repos. Store a Personal Access Token (GitHub: repo scope, + GitLab: read_repository) so deploys can clone without interactive prompts. + Only HTTPS repo URLs are supported; SSH URLs use the server's own key pair. +

+

{{git_token_status}}

+
+
+ + +
+
+ + {{git_token_clear_btn}} +
+
+
+

Environment Variables

@@ -145,6 +181,53 @@ async function deprovisionDb() { if (r.ok) window.location.reload(); else alert('Error: ' + await r.text()); } +const IS_PUBLIC = {{is_public_js}}; +async function saveSettings() { + const body = { + repo_url: document.getElementById('cfg-repo').value, + branch: document.getElementById('cfg-branch').value, + port: parseInt(document.getElementById('cfg-port').value, 10), + memory_limit: document.getElementById('cfg-memory').value, + cpu_limit: document.getElementById('cfg-cpu').value, + }; + const r = await fetch('/api/apps/' + APP_ID, { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(body), + }); + if (r.ok) window.location.reload(); + else alert('Error saving settings: ' + await r.text()); +} +async function toggleVisibility() { + const r = await fetch('/api/apps/' + APP_ID, { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({is_public: !IS_PUBLIC}), + }); + if (r.ok) window.location.reload(); + else alert('Error updating visibility: ' + await r.text()); +} +async function saveGitToken() { + const tok = document.getElementById('git-token-input').value; + if (!tok) { alert('Enter a token first'); return; } + const r = await fetch('/api/apps/' + APP_ID, { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({git_token: tok}), + }); + if (r.ok) window.location.reload(); + else alert('Error saving token: ' + await r.text()); +} +async function clearGitToken() { + if (!confirm('Remove the stored git token for ' + APP_ID + '?')) return; + const r = await fetch('/api/apps/' + APP_ID, { + method: 'PATCH', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({git_token: ''}), + }); + if (r.ok) window.location.reload(); + else alert('Error clearing token: ' + await r.text()); +} async function stopApp() { if (!confirm('Stop ' + APP_ID + '?')) return; const r = await fetch('/api/apps/' + APP_ID + '/stop', {method:'POST'});
Schema{schema}