From 19a0280d7dabd4ca637d8070cc7d3691a0e85bd1 Mon Sep 17 00:00:00 2001 From: Doan Luu Duc Tai Date: Fri, 30 Oct 2026 14:00:58 -0500 Subject: Manage the portable stack with Rust and rootless Podman --- .cargo/config.toml | 17 + .gitignore | 23 +- Caddyfile | 67 -- Cargo.lock | 650 ++++++++++++++ Cargo.toml | 114 +++ README.txt | 84 +- about.hbs | 44 + about.toml | 17 + crates/README.txt | 24 + deny.toml | 34 + docker-compose.yml | 43 - docs/OPERATIONS.txt | 322 +++++++ docs/POLICY.txt | 74 ++ infra/Caddyfile | 93 ++ infra/Caddyfile.local | 37 + infra/containers/cgit/.containerignore | 21 + infra/containers/cgit/Containerfile | 27 + infra/containers/cgit/cgitrc | 27 + infra/containers/cgit/cgitrc.local | 27 + infra/containers/cgit/entrypoint.sh | 20 + infra/containers/cgit/nginx.conf | 46 + infra/containers/cgit/supervisord.conf | 42 + infra/host-setup.sh | 28 + infra/searxng/settings.yml | 24 + infra/services.toml | 47 + infra/stack.service | 33 + rust-toolchain.toml | 20 + searxng/settings.yml | 24 - src/archive.rs | 406 +++++++++ src/files.rs | 336 +++++++ src/images.rs | 215 +++++ src/main.rs | 252 ++++++ src/process.rs | 70 ++ src/project.rs | 535 ++++++++++++ src/release.rs | 131 +++ .../openpgpkey/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp | Bin 0 -> 1286 bytes .../luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp | Bin 0 -> 1286 bytes web/.well-known/openpgpkey/luuduct.ai/policy | 0 web/.well-known/openpgpkey/policy | 0 web/THIRD-PARTY-LICENSES.html | 961 +++++++++++++++++++++ web/index.html | 91 ++ web/styles.css | 117 +++ .../openpgpkey/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp | Bin 427 -> 0 bytes .../luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp | Bin 427 -> 0 bytes www/.well-known/openpgpkey/luuduct.ai/policy | 0 www/.well-known/openpgpkey/policy | 0 www/index.html | 121 --- www/styles.css | 117 --- 48 files changed, 4979 insertions(+), 402 deletions(-) create mode 100644 .cargo/config.toml delete mode 100644 Caddyfile create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 about.hbs create mode 100644 about.toml create mode 100644 crates/README.txt create mode 100644 deny.toml delete mode 100644 docker-compose.yml create mode 100644 docs/OPERATIONS.txt create mode 100644 docs/POLICY.txt create mode 100644 infra/Caddyfile create mode 100644 infra/Caddyfile.local create mode 100644 infra/containers/cgit/.containerignore create mode 100644 infra/containers/cgit/Containerfile create mode 100644 infra/containers/cgit/cgitrc create mode 100644 infra/containers/cgit/cgitrc.local create mode 100644 infra/containers/cgit/entrypoint.sh create mode 100644 infra/containers/cgit/nginx.conf create mode 100644 infra/containers/cgit/supervisord.conf create mode 100755 infra/host-setup.sh create mode 100644 infra/searxng/settings.yml create mode 100644 infra/services.toml create mode 100644 infra/stack.service create mode 100644 rust-toolchain.toml delete mode 100644 searxng/settings.yml create mode 100644 src/archive.rs create mode 100644 src/files.rs create mode 100644 src/images.rs create mode 100644 src/main.rs create mode 100644 src/process.rs create mode 100644 src/project.rs create mode 100644 src/release.rs create mode 100644 web/.well-known/openpgpkey/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp create mode 100644 web/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp create mode 100644 web/.well-known/openpgpkey/luuduct.ai/policy create mode 100644 web/.well-known/openpgpkey/policy create mode 100644 web/THIRD-PARTY-LICENSES.html create mode 100644 web/index.html create mode 100644 web/styles.css delete mode 100644 www/.well-known/openpgpkey/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp delete mode 100644 www/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp delete mode 100644 www/.well-known/openpgpkey/luuduct.ai/policy delete mode 100644 www/.well-known/openpgpkey/policy delete mode 100644 www/index.html delete mode 100644 www/styles.css diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..e30612d --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,17 @@ +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +[alias] +stack = "run --locked --" diff --git a/.gitignore b/.gitignore index bba2338..0ec5a8e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,20 +13,11 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -# Environment & secrets -.env -*.rev -*.key -*.priv - -# Caddy TLS certs and runtime state (prevents permission errors) -caddy/ - -# Empty placeholder directories -git/ - -# OS / Editor clutter -.DS_Store +/.env +/var/ +/.tools/ +/target/ +/bin/ +/dist/ *.swp -*.bak -www/favicon.svg +.DS_Store diff --git a/Caddyfile b/Caddyfile deleted file mode 100644 index 862ba00..0000000 --- a/Caddyfile +++ /dev/null @@ -1,67 +0,0 @@ -# Copyright (C) 2026 Doan Luu Duc Tai -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -luuduct.ai { - handle /.well-known/openpgpkey/* { - root * /srv - header Content-Type application/octet-stream - file_server - } - - handle { - redir https://doan.luuduct.ai{uri} permanent - } -} - -www.luuduct.ai { - redir https://doan.luuduct.ai{uri} permanent -} - -doan.luuduct.ai { - root * /srv - file_server - - redir /git /git/ - - handle /send-message { - reverse_proxy host.docker.internal:8085 - } - - handle /cgit* { - reverse_proxy host.docker.internal:8082 - } - - handle /git/* { - reverse_proxy host.docker.internal:8082 - } -} - -openpgpkey.luuduct.ai { - handle /.well-known/openpgpkey/* { - root * /srv - header Content-Type application/octet-stream - file_server - } -} - -git.doan.luuduct.ai { - redir /{path}.git /{path} permanent - - reverse_proxy host.docker.internal:8082 -} - -search.doan.luuduct.ai { - reverse_proxy searxng:8080 -} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..13099a6 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,650 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "stack" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "dotenvy", + "flate2", + "hex", + "serde", + "serde_json", + "sha2", + "tar", + "tempfile", + "toml", + "walkdir", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "toml" +version = "1.1.8+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "xattr" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "963d6fab92d5504b971dc8bd90ca743c2575e590bc8071ea5bc571598667ae08" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..e08c687 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,114 @@ +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +[package] +name = "stack" +version = "0.1.0" +edition = "2024" +rust-version = "1.99" +authors = ["Doan Luu Duc Tai "] +license = "AGPL-3.0-or-later" +description = "Rust management, backup, and deployment for a personal web stack." +repository = "https://git.doan.luuduct.ai/stack" +readme = "README.txt" +keywords = ["self-hosting", "deployment", "backup", "podman", "cli"] +categories = ["command-line-utilities", "web-programming"] +publish = false +exclude = ["var", ".tools", "target", "bin", "dist"] + +[dependencies] +anyhow = "1.0" +chrono = { version = "0.4", default-features = false, features = ["now", "std"] } +clap = { version = "4.6", features = ["derive"] } +dotenvy = "0.15" +hex = "0.4" +flate2 = { version = "1", default-features = false, features = ["rust_backend"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha2 = "0.11" +tar = "0.4" +tempfile = "3" +toml = "1.1" +walkdir = "2" + +[profile.release] +opt-level = 3 +strip = true +lto = true +incremental = false + +[workspace] +members = ["."] +resolver = "3" + +[lints] +workspace = true + +[workspace.lints.rustdoc] +broken_intra_doc_links = "deny" +private_intra_doc_links = "deny" +missing_crate_level_docs = "deny" + +[workspace.lints.rust] +unused = { level = "deny", priority = -1 } +nonstandard_style = { level = "deny", priority = -1 } +future_incompatible = { level = "deny", priority = -1 } +rust_2018_idioms = { level = "deny", priority = -1 } +let_underscore = { level = "deny", priority = -1 } +unsafe_code = "deny" +unstable_features = "deny" +missing_docs = "deny" +missing_debug_implementations = "deny" +unreachable_pub = "deny" +trivial_casts = "deny" +trivial_numeric_casts = "deny" +unreachable_patterns = "deny" +warnings = "deny" +unused_qualifications = "deny" + +[workspace.lints.clippy] +all = { level = "deny", priority = -1 } +correctness = { level = "deny", priority = -1 } +suspicious = { level = "deny", priority = -1 } +cargo = { level = "deny", priority = -1 } +style = { level = "deny", priority = -1 } +complexity = { level = "deny", priority = -1 } +perf = { level = "deny", priority = -1 } +pedantic = { level = "deny", priority = -1 } +nursery = { level = "deny", priority = -1 } +todo = "deny" +print_stdout = "deny" +print_stderr = "deny" +unwrap_used = "deny" +expect_used = "deny" +dbg_macro = "deny" +mem_forget = "deny" +exit = "deny" +integer_division = "deny" +clone_on_ref_ptr = "deny" +indexing_slicing = "deny" +arithmetic_side_effects = "deny" +float_arithmetic = "deny" +as_conversions = "deny" +shadow_reuse = "deny" +shadow_same = "deny" +unseparated_literal_suffix = "deny" +undocumented_unsafe_blocks = "deny" +cast_possible_truncation = "deny" +cast_possible_wrap = "deny" +cast_sign_loss = "deny" +missing_errors_doc = "deny" +multiple_crate_versions = "allow" +redundant_pub_crate = "allow" diff --git a/README.txt b/README.txt index a461850..70a5931 100644 --- a/README.txt +++ b/README.txt @@ -1,18 +1,74 @@ -================================================================================ -SERVER STACK FOR https://doan.luuduct.ai (stack) -================================================================================ +Copyright (C) 2026 Doan Luu Duc Tai -Personal infrastructure stack. +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. -LICENSE --------------------------------------------------------------------------------- -All software, configuration files, and documentation in this repository are -licensed under the GNU Affero General Public License v3.0 or later -(AGPL-3.0-or-later). See the LICENSE file for details. +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. -Licensing Exceptions & Public Domain Dedications: +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . -- www/.well-known/openpgpkey/**: - Public cryptographic key material and Web Key Directory endpoints are - dedicated to the public domain under CC0 1.0 Universal: - https://creativecommons.org/publicdomain/zero/1.0/ +STACK + + Personal web services, developed and managed through a root Cargo workspace. + Copyright (C) 2026 Doan Luu Duc Tai + GNU Affero General Public License, version 3 or later. See LICENSE. + +GETTING STARTED + + Install Rust through rustup, Podman, Git, and GnuPG on a Linux development + machine. The pinned toolchain includes rustfmt, Clippy, and the static + x86_64 Linux target. + + From this directory: + + cargo stack dev + cargo stack status + cargo stack down + + Browse http://localhost:8080, /git/, and /search/. + A fresh checkout generates a private .env automatically. + Local mode binds only loopback and does not request public certificates. + +DEVELOPMENT AND PRODUCTION + + src/ holds the Rust management command. Future Rust application backends + belong in crates/. Add each crate to the root workspace explicitly and + inherit its strict workspace lints. + + infra/ holds service definitions, proxy configuration, container sources, + and the production user service. web/ holds the public website and WKD. + docs/ contains plaintext operations and policy documentation. + + Only .env holds configured secrets. .env.example documents the keys. + var/ contains private persistent data, container storage, temporary work, + and exports. Generated TLS private keys are runtime data under var/. + .env, var/, .tools/, target/, bin/, and dist/ are excluded from Git. + + The three existing services are Caddy, cgit, and SearXNG. The management + command is Rust. SearXNG and the cgit container's upstream supervisor retain + their third-party Python implementation. No first-party Python is needed. + + Build and test on the development machine. Deploy prebuilt release + archives. The production host needs Podman, Git, and GnuPG, but no Rust + toolchain, Cargo, Compose, or Docker daemon. + +CHECKS AND LICENSES + + cargo install --locked --version 0.20.2 cargo-deny --root .tools + cargo install --locked --version 0.9.3 --features cli cargo-about --root .tools + cargo stack check + cargo stack release + + Check runs formatting, Clippy, tests, rustdoc, cargo-deny, and cargo-about. + THIRD-PARTY-LICENSES.html is generated from Cargo.lock and about.hbs. + Human-readable license headings use GNU's names. SPDX identifiers and + original third-party legal texts retain their exact spelling. + + Detailed manual backup, recovery, deployment, and host setup instructions + are in docs/OPERATIONS.txt. Read docs/POLICY.txt before changing conventions. diff --git a/about.hbs b/about.hbs new file mode 100644 index 0000000..a2b47c5 --- /dev/null +++ b/about.hbs @@ -0,0 +1,44 @@ + + + + + + + + Stack — third-party licenses + + + +{{#*inline "gnu-name"}}{{#if (eq id "MIT")}}Expat License{{else if (eq id "Apache-2.0")}}Apache License, Version 2.0{{else if (eq id "BSD-3-Clause")}}Modified BSD License{{else if (eq id "BSD-2-Clause")}}FreeBSD License{{else if (eq id "0BSD")}}Zero-clause BSD License{{else if (eq id "Unicode-3.0")}}Unicode License v3{{else if (eq id "Zlib")}}License of ZLib{{else if (eq id "AGPL-3.0-or-later")}}GNU Affero General Public License, version 3 or later{{else}}{{name}}{{/if}}{{/inline}} +

Third-party licenses for Stack

+

Stack is covered by the GNU Affero General Public License, version 3 or later. +This page covers the Rust dependencies recorded in Cargo.lock. Supporting container +applications and their operating-system packages have their own notices.

+

Headings follow GNU's +license names where listed. SPDX identifiers remain available for exact identification. +The original license texts below are unchanged.

+ +{{#each licenses}} +
+{{#if first_of_kind}}

{{> gnu-name}}

{{/if}} + +
{{text}}
+
+{{/each}} + + diff --git a/about.toml b/about.toml new file mode 100644 index 0000000..7ed8f8c --- /dev/null +++ b/about.toml @@ -0,0 +1,17 @@ +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +accepted = ["MIT", "Apache-2.0", "BSD-3-Clause", "Unicode-3.0", "Zlib", "0BSD"] +private = { ignore = true } diff --git a/crates/README.txt b/crates/README.txt new file mode 100644 index 0000000..296f1a9 --- /dev/null +++ b/crates/README.txt @@ -0,0 +1,24 @@ +Copyright (C) 2026 Doan Luu Duc Tai + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +RUST BACKENDS + + New first-party web backends belong in subdirectories here. Add their + paths to the workspace members in the root Cargo.toml. Use edition 2024, + AGPL-3.0-or-later, and workspace lints. Keep configured secrets in the one + root .env and pass only the required keys to each service. + + The current stack has no blog or comment backend. This directory reserves + a place for those features without adding an unused service or database. diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..1b21572 --- /dev/null +++ b/deny.toml @@ -0,0 +1,34 @@ +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +[licenses] +allow = ["Apache-2.0", "BSD-3-Clause", "Unicode-3.0", "MIT", "0BSD", "Zlib"] + +[[licenses.exceptions]] +name = "stack" +allow = ["AGPL-3.0-or-later"] + +[bans] +multiple-versions = "deny" +multiple-versions-include-dev = true +wildcards = "deny" +skip = [] + +[advisories] +yanked = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index cd92b8b..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,43 +0,0 @@ -# Copyright (C) 2026 Doan Luu Duc Tai -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -services: - caddy: - image: caddy:2-alpine - restart: unless-stopped - ports: - - "80:80" - - "443:443" - extra_hosts: - - "host.docker.internal:host-gateway" - volumes: - - ./Caddyfile:/etc/caddy/Caddyfile:ro - - ./caddy/data:/data - - ./caddy/config:/config - - ./www:/srv:ro - networks: [gateway] - - searxng: - image: searxng/searxng:latest - restart: unless-stopped - cap_drop: [ALL] - security_opt: [no-new-privileges:true] - env_file: .env - volumes: - - ./searxng:/etc/searxng - networks: [gateway] - -networks: - gateway: diff --git a/docs/OPERATIONS.txt b/docs/OPERATIONS.txt new file mode 100644 index 0000000..6cd9a6b --- /dev/null +++ b/docs/OPERATIONS.txt @@ -0,0 +1,322 @@ +Copyright (C) 2026 Doan Luu Duc Tai + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +STACK OPERATIONS AND RECOVERY + + Application root: ~/stack + Development machine: local Linux workstation + Production host: ops@23.155.44.67 + Public source: https://git.doan.luuduct.ai/stack + License: GNU Affero General Public License, version 3 or later + +WHAT YOU NEED TO KEEP + + A full encrypted export made with --include-images contains public source, + the compiled management command, .env, persistent application data, + certificate state, bare repositories, working Git history if present, and + the exact service images. It includes a file checksum manifest. + + It excludes the container engine's storage, build caches, old exports, + host packages, host SSH keys, firewall rules, disks, and OS configuration. + These are recreated on a new host. No old server filesystem is required. + + The public Git repository alone is not a backup: it excludes .env, live + data, private certificates, and application repositories under var/. + + Your GPG private key and its passphrase are needed to decrypt backups. + Keep the private-key export and revocation certificate in KeePassXC and + keep an independent copy of the database. The server has only the public + key. This guide never needs your private key or password in chat. + + Default backup recipient fingerprint: + 3346AA3150F8F16828BB0D14F8C66DF77AFECEA6 + +DAILY DEVELOPMENT + + Run these from ~/stack on your workstation: + + cargo stack dev + cargo stack status + cargo stack down + + Visit http://localhost:8080, http://localhost:8080/git/, and + http://localhost:8080/search/. To use different ports: + + cargo stack dev --port 9080 + + Local mode binds loopback only, rewrites public service links locally, + keeps public DNS untouched, and does not request public TLS certificates. + It automatically creates .env with a random SearXNG secret if absent. + Edit .env to change configured secrets. Do not commit it. + + Public service settings belong in infra/, and website content in web/. + New Rust backends belong in crates/, explicitly added to Cargo.toml. + Services are declared in infra/services.toml. A service receives only the + .env keys listed in its environment array, not every project secret. + + Rust commands use a project-specific Podman store under var/podman/. + A bare 'podman ps' lists your normal store, which may be empty. To inspect + this project's store manually, run from ~/stack: + + podman --root "$PWD/var/podman/storage" --runroot "$PWD/var/podman/run" ps -a + + Reuse those two global options before 'logs CONTAINER', 'exec CONTAINER', + or 'images'. Container names are shown by cargo stack status. + Never copy var/podman/ to another machine; export images instead. + +STRICT LOCAL CHECKS + + Install the two development-only tools once: + + cargo install --locked --version 0.20.2 cargo-deny --root .tools + cargo install --locked --version 0.9.3 --features cli cargo-about --root .tools + + Then run: + + cargo stack check + + This checks formatting, all workspace Clippy targets/features, tests, + private rustdoc, dependency advisories, duplicate versions, sources, and + licenses. It regenerates THIRD-PARTY-LICENSES.html through cargo-about. + Use cargo fmt --all when formatting needs correction. + + After changing dependency requirements, update Cargo.lock deliberately, + run the complete check, and commit the lockfile and generated license page. + Never disable audit failures merely to get a release out. + +EXPORT AND BACK UP PRODUCTION + + On the VPS: + + cd ~/stack + ./bin/stack backup --include-images + + The command briefly pauses the three services while copying consistent + application files, then resumes them before compression and encryption. + It uses the published public key, verifies the recipient fingerprint, + checks bare Git repositories, saves exact images, and produces: + + var/exports/stack-TIMESTAMP.tar.gz.gpg + var/exports/stack-TIMESTAMP.tar.gz.gpg.sha256 + + Both files have restrictive permissions. Temporary plaintext staging is + removed on normal completion and handled errors. No scheduled backup job + is installed. You decide when and where to retain exports. + + On your workstation, create a destination under stack and copy the two + actual filenames printed by the command: + + mkdir -p ~/stack/var/backups/production + scp ops@23.155.44.67:stack/var/exports/stack-TIMESTAMP.tar.gz.gpg ~/stack/var/backups/production/ + scp ops@23.155.44.67:stack/var/exports/stack-TIMESTAMP.tar.gz.gpg.sha256 ~/stack/var/backups/production/ + cd ~/stack/var/backups/production + sha256sum -c stack-TIMESTAMP.tar.gz.gpg.sha256 + + Replace TIMESTAMP with the actual timestamp. A checksum detects accidental + corruption; keep the trusted encrypted file and checksum together. + Retain at least one copy on another device or location. + + Once copied and verified, delete only the corresponding VPS export files + if desired. Do not delete var/data or .env. + + A smaller export without images is available through ./bin/stack backup. + It needs registry/build access for local startup and is not a standalone + production deployment. Prefer --include-images for disaster recovery. + +RESTORE ON ANOTHER LINUX MACHINE + + Install Podman, Git, GnuPG, and normal rootless networking prerequisites. + Rootless Podman needs at least 65536 subordinate UIDs and GIDs for the + target user. Use that distribution's supported Podman installation. + The current ready-made binary/images target x86_64 Linux (linux/amd64). + + Copy the encrypted export and its checksum to the new machine. Verify + the checksum first. Import your secret-key backup if not already present: + + gpg --import private-key-backup.asc + + GnuPG asks for the key passphrase through its normal local interface. + Never pass it on the command line or put it in .env. + + If you already have a compatible stack command, restore with it: + + ./bin/stack restore /path/to/stack-TIMESTAMP.tar.gz.gpg ~/stack-restored + + The destination must not exist. Restore decrypts privately, rejects unsafe + archive paths and links, checks every manifest entry, assigns a unique + project identity, and defaults to local mode. It does not start services. + It refuses to overwrite an existing installation. + + For a new machine with no stack command or Rust toolchain, bootstrap with + standard GnuPG and tar. The shell commands below work in bash and fish: + + umask 077 + mkdir -p ~/stack-bootstrap + gpg --output ~/stack-bootstrap/export.tar.gz --decrypt /path/to/stack-TIMESTAMP.tar.gz.gpg + tar -xzf ~/stack-bootstrap/export.tar.gz -C ~/stack-bootstrap stack/bin/stack + ~/stack-bootstrap/stack/bin/stack restore /path/to/stack-TIMESTAMP.tar.gz.gpg ~/stack + + Only use this bootstrap with your own trusted export; the extracted binary + is executable code. The restore command validates the complete archive. + After successful restoration, delete ~/stack-bootstrap, which contains + the temporary decrypted archive. Keep only the encrypted export as backup. + + Start the recovered stack locally first: + + cd ~/stack + ./bin/stack up --local --port 8080 + ./bin/stack status + + Saved images load automatically and their identities are checked against + the image lock. No registry pull or compilation is needed on a matching + architecture. Check the homepage, Git repositories, search, and WKD. + Then stop it with ./bin/stack down before configuring production. + + A release archive can be restored by the same command. Releases contain + source, a binary, and images, but no .env or application data. Starting a + restored release creates fresh state; it does not recover lost data. + +SET UP A NEW PRODUCTION HOST + + Restore to the target user's ~/stack, using the steps above. On Debian, + adapt the username 'ops' in infra/host-setup.sh if necessary, review it, + and run: + + sudo sh ~/stack/infra/host-setup.sh + + It installs Podman and networking tools, checks existing subordinate + UID/GID ranges, enables user linger for boot, and sets: + + net.ipv4.ip_unprivileged_port_start=80 + + This allows rootless listeners on ports 80 and above system-wide; ports + below 80 remain privileged. It does not change SSH or your firewall. + If the user lacks subordinate ranges, allocate unused ranges through + the distribution's account administration before rerunning the script. + + On other Linux distributions, install equivalent prerequisites and set + that sysctl and user linger using the host's supported tools. Production + requires a systemd user manager for this project's supplied boot service. + + Allow inbound 22/tcp, 80/tcp, 443/tcp, and 443/udp through the host/provider + firewall, for IPv4 and IPv6 as applicable. Backend container ports must not + be published. Preserve your SSH access and verify the address before DNS + changes. There is no need to restore Docker's old generated firewall rules. + + Point public A records to the new IPv4 address, and AAAA records only to a + working new IPv6 address. Remove stale AAAA records. Public hostnames are + listed in infra/Caddyfile. Update canonical hosts there if changing domains. + Keep the www aliases and openpgpkey names pointing to the canonical hosts. + + Start production: + + cd ~/stack + ./bin/stack up --production + + Production binds 80/tcp, 443/tcp, and 443/udp. Caddy manages certificates + and renewals automatically, redirects HTTP to HTTPS, and redirects www + aliases to the canonical URLs. Certificate state is in var/data/caddy/. + Public certificate issuance needs working public DNS and connectivity. + + Enable startup at boot: + + systemctl --user link ~/stack/infra/stack.service + systemctl --user daemon-reload + systemctl --user enable --now stack.service + systemctl --user status stack.service + + This unit assumes ~/stack. Edit its paths if deliberately using another + location. User linger keeps it running without an interactive login. + Application containers use an unless-stopped restart policy. + + If offering SSH Git access at the former VPS path, create this optional + host symlink as the administrator: + + sudo install -d -m 755 /srv/git + sudo ln -s /home/ops/stack/var/data/repos/stack /srv/git/stack + + Do not overwrite an existing repository there. The current VPS already + has this link, updated for the new layout. Public HTTPS Git works without + the optional host link. + +MAKE AND DEPLOY A RELEASE + + Develop and run the checks on your workstation, then: + + cd ~/stack + cargo stack release + + Release reruns the full check, builds a static Rust binary locally, + builds the cgit container locally, and packages the configured image set. + Outputs are dist/stack-release-TIMESTAMP.tar.gz and its .sha256 file. + No .env, live data, .git history, or previous backup is included. + + Test the release in a fresh local directory before deploying, using the + restore command followed by up --local on an unused port. Inspect the + same four services and then stop the test installation. + + Commit and push reviewed public changes with normal Git: + + git add -A + git diff --cached --stat + git commit -m 'Describe the change' + git push origin main + + .gitignore excludes configured secrets and all runtime/artifact trees. + Review the staged files. Deployment uses the release archive, not git pull. + + Copy the actual archive and checksum to production: + + ssh ops@23.155.44.67 'mkdir -p ~/stack/var/incoming' + scp dist/stack-release-TIMESTAMP.tar.gz ops@23.155.44.67:stack/var/incoming/ + scp dist/stack-release-TIMESTAMP.tar.gz.sha256 ops@23.155.44.67:stack/var/incoming/ + + On the VPS: + + cd ~/stack/var/incoming + sha256sum -c stack-release-TIMESTAMP.tar.gz.sha256 + cd ~/stack + ./bin/stack activate var/incoming/stack-release-TIMESTAMP.tar.gz + ./bin/stack status + + Activation verifies the release and platform, replaces public source and + the binary, loads exact images, preserves .env and var/data, and replaces + the running containers. It does not compile Rust or build container images. + Expect a brief interruption while containers are replaced. Take a verified + export first if a future change could require recovery. + + After verifying production, delete the corresponding incoming archive and + checksum if you do not want release copies left on the VPS. + +RECOVERY LIMITS AND SECRET ROTATION + + Exports capture this application, not an entire bootable VPS image. + Host account, SSH, firewall, and package setup remain host responsibilities. + Changing CPU architecture requires a native release build. Registry access + is needed only when images are omitted or rebuilding for another target. + + Current persistent state is files, Git repositories, and Caddy state. + When adding a database, add its supported flush/dump procedure to the + export workflow and test recovery before relying on that database's backup. + + If changing the OpenPGP backup recipient, publish its public key, update + the command's default fingerprint, and test encryption and restoration. + For one export, use --recipient FULL_FINGERPRINT --key /path/public-key.asc. + Keep old secret keys while any retained export still depends on them. + + Legacy format-1 exports made before the Rust migration use their embedded + historical Python recovery helper. Keep that helper with those exports. + The current command intentionally accepts only its verified format-2 + archives. Fresh format-2 production backups replace the normal workflow. diff --git a/docs/POLICY.txt b/docs/POLICY.txt new file mode 100644 index 0000000..f9e3614 --- /dev/null +++ b/docs/POLICY.txt @@ -0,0 +1,74 @@ +Copyright (C) 2026 Doan Luu Duc Tai + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, either version 3 of the +License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +PROJECT CONVENTIONS + + License: GNU Affero General Public License, version 3 or later. + SPDX identifier: AGPL-3.0-or-later. + Copyright holder: Doan Luu Duc Tai , 2026. + + Every nontrivial first-party source, configuration, template, and prose + file carries the AGPL copyright and permission header. LICENSE is the + unmodified license text. Binary public keys and empty WKD policy files + retain their protocol format. Generated Cargo.lock retains Cargo's own + metadata as well as the project notice. + + Do not add explanatory source comments. Express intent through structure + and names. Legal headers are the required exception. Use .txt for prose + documentation. The generated HTML license page is an explicit exception. + Rust documentation attributes provide crate metadata without comments. + + Keep configured secrets only in the root .env. Never commit .env or var/. + .env.example lists required keys with placeholders. Public hostnames, + public GPG keys, paths, configuration, and source belong in Git. + TLS account/certificate private keys, databases, and other generated + private state are persistent data in var/data, not .env configuration. + The GPG secret key and revocation certificate remain outside this project. + +STRICTNESS + + Preserve the requested Rust and Clippy deny groups, including pedantic + and nursery. The one additional exception is redundant_pub_crate: its + advice to make internal binary items public conflicts with unreachable_pub, + which remains denied. Internal interfaces remain explicitly crate-private. + No unsafe code, unwrap, expect, todo, debug output, arithmetic shortcuts, + or broad warning suppression is introduced. + + cargo-deny rejects duplicate crate versions, wildcard requirements, unknown + registries, unknown Git sources, and yanked versions. There are no duplicate + version skip exceptions. Its AGPL allowance is scoped to this package. + The dependency allowlist retains the requested permissive licenses and + Zlib; an unused allowance may produce an informational audit warning. + + Human license names follow https://www.gnu.org/licenses/license-list.html. + In particular, display Expat License for SPDX MIT. Do not alter original + copyright notices, legal texts, crate metadata, or machine SPDX identifiers. + cargo-about covers Rust dependencies. Supporting container applications + and their operating-system packages carry additional upstream licenses. + +PORTABILITY + + All first-party project files and state live below stack/. Standard host + integration consists of packages, subordinate UID/GID allocations, one + low-port sysctl setting, user linger, and a systemd user-service link. + Host SSH keys, firewall policy, accounts, and disks are host administration, + not application export contents. + + Releases currently contain a static x86_64 Linux command and native + linux/amd64 container images. They are portable across compatible x86_64 + Linux machines. An ARM machine needs a native release build; the command + rejects mismatched production images. Development mode may fetch/build + native images on an architecture mismatch. Container source and Rust + source are public so new targets can be added without restoring an OS. diff --git a/infra/Caddyfile b/infra/Caddyfile new file mode 100644 index 0000000..3146529 --- /dev/null +++ b/infra/Caddyfile @@ -0,0 +1,93 @@ +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +(https_security) { + header >Strict-Transport-Security "max-age=31536000" +} + +luuduct.ai { + import https_security + handle /.well-known/openpgpkey/* { + root * /srv + header Content-Type application/octet-stream + file_server + } + + handle { + redir https://doan.luuduct.ai{uri} permanent + } +} + +www.luuduct.ai { + import https_security + redir https://doan.luuduct.ai{uri} permanent +} + +doan.luuduct.ai { + import https_security + root * /srv + file_server + + redir /git /git/ + + handle /cgit* { + reverse_proxy cgit:8080 + } + + handle /git/* { + reverse_proxy cgit:8080 + } +} + +openpgpkey.luuduct.ai { + import https_security + handle /.well-known/openpgpkey/* { + root * /srv + header Content-Type application/octet-stream + file_server + } +} + +git.doan.luuduct.ai { + import https_security + redir /{path}.git /{path} permanent + + reverse_proxy cgit:8080 +} + +search.doan.luuduct.ai { + import https_security + reverse_proxy searxng:8080 +} + +www.doan.luuduct.ai { + import https_security + redir https://doan.luuduct.ai{uri} permanent +} + +www.git.doan.luuduct.ai { + import https_security + redir https://git.doan.luuduct.ai{uri} permanent +} + +www.search.doan.luuduct.ai { + import https_security + redir https://search.doan.luuduct.ai{uri} permanent +} + +www.openpgpkey.luuduct.ai { + import https_security + redir https://openpgpkey.luuduct.ai{uri} permanent +} diff --git a/infra/Caddyfile.local b/infra/Caddyfile.local new file mode 100644 index 0000000..03b4a16 --- /dev/null +++ b/infra/Caddyfile.local @@ -0,0 +1,37 @@ +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +:80 { + @git path /git /git/* /cgit.js /cgit.png + handle @git { + redir /git /git/ + uri strip_prefix /git + reverse_proxy cgit:8080 + } + + @search path /search /search/* + handle @search { + redir /search /search/ + uri strip_prefix /search + reverse_proxy searxng:8080 { + header_up X-Script-Name /search + } + } + + handle { + root * /srv + file_server + } +} diff --git a/infra/containers/cgit/.containerignore b/infra/containers/cgit/.containerignore new file mode 100644 index 0000000..4fb13e4 --- /dev/null +++ b/infra/containers/cgit/.containerignore @@ -0,0 +1,21 @@ +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +* +!Containerfile +!entrypoint.sh +!nginx.conf +!supervisord.conf +!cgitrc diff --git a/infra/containers/cgit/Containerfile b/infra/containers/cgit/Containerfile new file mode 100644 index 0000000..3bd130a --- /dev/null +++ b/infra/containers/cgit/Containerfile @@ -0,0 +1,27 @@ +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +FROM docker.io/library/debian:trixie-slim@sha256:1e1a22175fbf1b32157ddaf0219e1db4e5401fab2b94ed285add64d18587cd2d +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + cgit fcgiwrap nginx-light supervisor curl ca-certificates git \ + && rm -rf /var/lib/apt/lists/* /etc/nginx/sites-enabled/default +COPY nginx.conf /etc/nginx/nginx.conf +COPY supervisord.conf /etc/supervisor/stack.conf +COPY cgitrc /etc/cgitrc +COPY entrypoint.sh /usr/local/bin/stack-cgit +RUN chmod 755 /usr/local/bin/stack-cgit +EXPOSE 8080 +ENTRYPOINT ["/usr/local/bin/stack-cgit"] diff --git a/infra/containers/cgit/cgitrc b/infra/containers/cgit/cgitrc new file mode 100644 index 0000000..0b563e0 --- /dev/null +++ b/infra/containers/cgit/cgitrc @@ -0,0 +1,27 @@ +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +root-title=Doan Luu Duc Tai's repositories +root-desc=Personal git repos +virtual-root=/ +clone-prefix=https://git.doan.luuduct.ai +css=/cgit.css +favicon=/favicon.ico +remove-suffix=1 +defbranch=main +cache-root=/var/cache/cgit +cache-size=1000 +cache-scanrc-ttl=1 +scan-path=/srv/git diff --git a/infra/containers/cgit/cgitrc.local b/infra/containers/cgit/cgitrc.local new file mode 100644 index 0000000..3c8d657 --- /dev/null +++ b/infra/containers/cgit/cgitrc.local @@ -0,0 +1,27 @@ +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +root-title=Doan Luu Duc Tai's repositories (local) +root-desc=Restored local copy +virtual-root=/git/ +clone-prefix=http://localhost:8080/git +css=/git/cgit.css +favicon=/git/favicon.ico +remove-suffix=1 +defbranch=main +cache-root=/var/cache/cgit +cache-size=1000 +cache-scanrc-ttl=1 +scan-path=/srv/git diff --git a/infra/containers/cgit/entrypoint.sh b/infra/containers/cgit/entrypoint.sh new file mode 100644 index 0000000..5de3a06 --- /dev/null +++ b/infra/containers/cgit/entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +set -eu +mkdir -p /run/fcgiwrap /var/cache/cgit +chown www-data:www-data /run/fcgiwrap /var/cache/cgit +exec /usr/bin/supervisord -n -c /etc/supervisor/stack.conf diff --git a/infra/containers/cgit/nginx.conf b/infra/containers/cgit/nginx.conf new file mode 100644 index 0000000..df50345 --- /dev/null +++ b/infra/containers/cgit/nginx.conf @@ -0,0 +1,46 @@ +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +user www-data; +worker_processes auto; +pid /run/nginx.pid; +error_log /dev/stderr warn; +events { worker_connections 1024; } +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + access_log /dev/stdout; + map $http_x_forwarded_proto $cgi_https { + default ""; + https on; + } + server { + listen 8080; + server_name _; + root /usr/share/cgit; + location ~* ^/(cgit\.(css|png|js)|favicon\.ico|robots\.txt)$ { + expires 30d; + } + location / { + include /etc/nginx/fastcgi_params; + fastcgi_param SCRIPT_FILENAME /usr/lib/cgit/cgit.cgi; + fastcgi_param PATH_INFO $uri; + fastcgi_param QUERY_STRING $args; + fastcgi_param HTTP_HOST $http_host; + fastcgi_param HTTPS $cgi_https; + fastcgi_pass unix:/run/fcgiwrap/socket; + } + } +} diff --git a/infra/containers/cgit/supervisord.conf b/infra/containers/cgit/supervisord.conf new file mode 100644 index 0000000..1a93bb5 --- /dev/null +++ b/infra/containers/cgit/supervisord.conf @@ -0,0 +1,42 @@ +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +[supervisord] +nodaemon=true +user=root +logfile=/dev/null +pidfile=/run/supervisord.pid + +[program:fcgiwrap] +command=/usr/sbin/fcgiwrap -f -s unix:/run/fcgiwrap/socket +user=www-data +umask=0007 +autorestart=true +stopasgroup=true +killasgroup=true +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/fd/2 +stderr_logfile_maxbytes=0 + +[program:nginx] +command=/usr/sbin/nginx -g "daemon off;" +autorestart=true +stopasgroup=true +killasgroup=true +stdout_logfile=/dev/fd/1 +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/fd/2 +stderr_logfile_maxbytes=0 diff --git a/infra/host-setup.sh b/infra/host-setup.sh new file mode 100755 index 0000000..e73645b --- /dev/null +++ b/infra/host-setup.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +set -eu +[ "$(id -u)" = 0 ] || { echo 'Run this script through sudo.' >&2; exit 1; } +id ops >/dev/null +apt-get update +apt-get install -y --no-install-recommends podman uidmap passt slirp4netns fuse-overlayfs netavark aardvark-dns +awk -F: '$1 == "ops" && $3 >= 65536 { found=1 } END { exit !found }' /etc/subuid +awk -F: '$1 == "ops" && $3 >= 65536 { found=1 } END { exit !found }' /etc/subgid +install -d -m 755 /etc/sysctl.d +printf '%s\n' 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/90-stack-rootless-ports.conf +sysctl -p /etc/sysctl.d/90-stack-rootless-ports.conf +loginctl enable-linger ops +printf '%s\n' 'Podman prerequisites ready. Existing production services remain running.' diff --git a/infra/searxng/settings.yml b/infra/searxng/settings.yml new file mode 100644 index 0000000..f8f7d6b --- /dev/null +++ b/infra/searxng/settings.yml @@ -0,0 +1,24 @@ +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +use_default_settings: true + +server: + limiter: true + public_instance: false + +search: + formats: + - html diff --git a/infra/services.toml b/infra/services.toml new file mode 100644 index 0000000..97091cf --- /dev/null +++ b/infra/services.toml @@ -0,0 +1,47 @@ +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +format = 1 + +[services.caddy] +image = "docker.io/library/caddy:2-alpine@sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648" +gateway = true +health = ["caddy", "validate", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] +mounts = [ + { source = "@caddy", target = "/etc/caddy/Caddyfile", read_only = true }, + { source = "var/data/caddy/data", target = "/data" }, + { source = "var/data/caddy/config", target = "/config" }, + { source = "@web", target = "/srv", read_only = true }, +] + +[services.cgit] +image = "localhost/stack-cgit:0.1.0" +build = "infra/containers/cgit" +health = ["curl", "--fail", "--silent", "--max-time", "3", "http://127.0.0.1:8080/"] +mounts = [ + { source = "var/data/repos", target = "/srv/git", read_only = true }, + { source = "@cgit", target = "/etc/cgitrc", read_only = true }, +] +tmpfs = ["/run", "/var/cache/cgit"] + +[services.searxng] +image = "docker.io/searxng/searxng:latest@sha256:1dab138ea70a8ceb4d1c182f5c8f256088406c1742b99e279976157dbe1e9759" +environment = ["SEARXNG_SECRET"] +drop_capabilities = true +health = ["wget", "--quiet", "--spider", "http://127.0.0.1:8080/"] +mounts = [ + { source = "var/data/searxng", target = "/etc/searxng" }, +] +tmpfs = ["/var/cache/searxng:mode=1777"] diff --git a/infra/stack.service b/infra/stack.service new file mode 100644 index 0000000..64012a9 --- /dev/null +++ b/infra/stack.service @@ -0,0 +1,33 @@ +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +[Unit] +Description=Personal web stack +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=%h/stack +ExecStart=%h/stack/bin/stack up --production +ExecStop=%h/stack/bin/stack down +TimeoutStartSec=300 +TimeoutStopSec=120 +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=default.target diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..7c40415 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,20 @@ +# Copyright (C) 2026 Doan Luu Duc Tai +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +[toolchain] +channel = "1.99.1" +profile = "minimal" +components = ["clippy", "rustfmt"] +targets = ["x86_64-unknown-linux-musl"] diff --git a/searxng/settings.yml b/searxng/settings.yml deleted file mode 100644 index f8f7d6b..0000000 --- a/searxng/settings.yml +++ /dev/null @@ -1,24 +0,0 @@ -# Copyright (C) 2026 Doan Luu Duc Tai -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . - -use_default_settings: true - -server: - limiter: true - public_instance: false - -search: - formats: - - html diff --git a/src/archive.rs b/src/archive.rs new file mode 100644 index 0000000..e7e6bc5 --- /dev/null +++ b/src/archive.rs @@ -0,0 +1,406 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; +use std::process::Command; + +use anyhow::{Context, Result, ensure}; +use serde_json::Value; + +use crate::project::{Mode, Project, Settings, field}; +use crate::{files, process}; + +pub(crate) const RECIPIENT: &str = "3346AA3150F8F16828BB0D14F8C66DF77AFECEA6"; +const KEY: &str = "web/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp"; + +#[derive(Debug)] +struct Paused<'a> { + project: &'a Project, + ids: Vec, +} + +impl Paused<'_> { + fn resume(&mut self) -> Result<()> { + let mut failure = None; + for id in self.ids.iter().rev() { + if let Err(error) = + process::capture(self.project.command("podman")?.args(["unpause", id])) + { + process::error(format_args!("Could not resume container {id}: {error:#}"))?; + failure = Some(error); + } + } + self.ids.clear(); + if let Some(error) = failure { + return Err(error); + } + Ok(()) + } +} + +impl Drop for Paused<'_> { + fn drop(&mut self) { + if let Err(error) = self.resume() { + drop(process::error(format_args!( + "Container resume failed: {error:#}" + ))); + } + } +} + +pub(crate) fn containers(project: &Project, settings: &Settings) -> Result> { + let records = project.inspect_containers(settings)?; + let actual: BTreeSet<_> = records + .iter() + .map(|record| { + field(record, &["Config", "Labels", "io.stack.service"])? + .as_str() + .context("Missing service label") + }) + .collect::>()?; + let configuration = project.configuration(settings)?; + let expected: BTreeSet<_> = configuration.services.keys().map(String::as_str).collect(); + ensure!( + actual == expected, + "Running services differ from configuration; refusing an incomplete backup" + ); + Ok(records) +} + +fn snapshot(project: &Project, records: &[Value], stage: &Path) -> Result<()> { + let mut guard = Paused { + project, + ids: Vec::new(), + }; + for record in records { + if field(record, &["State", "Running"])?.as_bool() == Some(true) + && field(record, &["State", "Paused"])?.as_bool() == Some(false) + { + let id = field(record, &["Id"])? + .as_str() + .context("Missing container ID")?; + process::capture(project.command("podman")?.args(["pause", id]))?; + guard.ids.push(id.to_owned()); + } + } + let result = snapshot_files(project, records, stage); + let resume = guard.resume(); + result?; + resume +} + +fn snapshot_files(project: &Project, records: &[Value], stage: &Path) -> Result<()> { + files::copy_source(&project.root, stage)?; + files::copy_tree(&project.root.join(".env"), &stage.join(".env"))?; + if project.root.join(".git").is_dir() { + files::copy_tree(&project.root.join(".git"), &stage.join(".git"))?; + } + let data = project.root.join("var/data"); + for item in fs::read_dir(&data)? { + let entry = item?; + if entry.file_name() != "caddy" { + files::copy_tree( + &entry.path(), + &stage.join("var/data").join(entry.file_name()), + )?; + } + } + let caddy = records + .iter() + .find(|record| { + field(record, &["Config", "Labels", "io.stack.service"]) + .ok() + .and_then(Value::as_str) + == Some("caddy") + }) + .context("Caddy is not present")?; + let id = field(caddy, &["Id"])?.as_str().context("Caddy has no ID")?; + let target = stage.join("var/data/caddy"); + fs::create_dir_all(&target)?; + for name in ["data", "config"] { + process::capture( + project + .command("podman")? + .args(["cp", &format!("{id}:/{name}")]) + .arg(&target), + )?; + } + fs::create_dir_all(stage.join("bin"))?; + let executable = [ + project.root.join("bin/stack"), + project + .root + .join("target/x86_64-unknown-linux-musl/release/stack"), + ] + .into_iter() + .find(|path| path.is_file()) + .context("Build a release locally before exporting so the backup includes a portable binary")?; + fs::copy(executable, stage.join("bin/stack"))?; + Ok(()) +} + +pub(crate) fn backup( + project: &Project, + destination: &Path, + with_images: bool, + recipient: &str, + public_key: Option<&Path>, +) -> Result<()> { + ensure!( + recipient.len() == 40 && recipient.bytes().all(|byte| byte.is_ascii_hexdigit()), + "A full 40-character recipient fingerprint is required" + ); + files::private_dir(destination)?; + let settings = project.settings()?; + let records = containers(project, &settings)?; + let temp = tempfile::Builder::new() + .prefix(".export-") + .tempdir_in(destination)?; + let home = temp.path().join("gnupg"); + files::private_dir(&home)?; + let key = public_key.map_or_else(|| project.root.join(KEY), Path::to_path_buf); + let operation = backup_inner( + project, + destination, + with_images, + &Encryption { + recipient, + key: &key, + home: &home, + }, + &records, + temp.path(), + ); + drop(process::capture( + Command::new("gpgconf") + .arg("--homedir") + .arg(&home) + .args(["--kill", "all"]), + )); + operation +} + +#[derive(Debug)] +struct Encryption<'a> { + recipient: &'a str, + key: &'a Path, + home: &'a Path, +} + +fn backup_inner( + project: &Project, + destination: &Path, + with_images: bool, + encryption: &Encryption<'_>, + records: &[Value], + temp: &Path, +) -> Result<()> { + let Encryption { + recipient, + key, + home, + } = encryption; + process::capture( + Command::new("gpg") + .args(["--no-options", "--batch", "--homedir"]) + .arg(home) + .args(["--import"]) + .arg(key), + )?; + let listed = process::text( + Command::new("gpg") + .args(["--no-options", "--batch", "--homedir"]) + .arg(home) + .args(["--with-colons", "--list-keys", recipient]), + )?; + ensure!( + listed + .lines() + .filter(|line| line.starts_with("fpr:")) + .any(|line| line + .split(':') + .nth(9) + .is_some_and(|found| found.eq_ignore_ascii_case(recipient))), + "Public key fingerprint does not match the requested backup recipient" + ); + let stage = temp.join("stack"); + files::private_dir(&stage)?; + snapshot(project, records, &stage)?; + for entry in fs::read_dir(stage.join("var/data/repos"))? { + let repo = entry?.path(); + if repo.is_dir() { + process::capture( + Command::new("git") + .arg("--git-dir") + .arg(repo) + .args(["fsck", "--full"]), + )?; + } + } + if with_images { + crate::images::save(project, records, &stage.join("var"))?; + } + files::manifest(&stage, "backup")?; + let plain = temp.join("stack.tar.gz"); + files::pack(&stage, &plain)?; + let name = format!( + "stack-{}.tar.gz.gpg", + chrono::Utc::now().format("%Y%m%dT%H%M%SZ") + ); + let encrypted = temp.join(&name); + process::run( + Command::new("gpg") + .args(["--no-options", "--batch", "--homedir"]) + .arg(home) + .args([ + "--rfc4880", + "--trust-model", + "always", + "--recipient", + recipient, + "--cipher-algo", + "AES256", + "--compress-algo", + "none", + "--set-filename", + "stack.tar.gz", + "--output", + ]) + .arg(&encrypted) + .arg("--encrypt") + .arg(plain), + )?; + let output = destination.join(&name); + ensure!( + !output.exists(), + "An export with this timestamp already exists" + ); + fs::rename(&encrypted, &output)?; + let digest = format!("{} {name}\n", files::hash_file(&output)?); + files::write( + &destination.join(format!("{name}.sha256")), + digest.as_bytes(), + 0o600, + )?; + process::line(format_args!("Encrypted export: {}", output.display())) +} + +pub(crate) fn restore(archive: &Path, destination: &Path) -> Result<()> { + ensure!(!destination.exists(), "Restore destination must not exist"); + let parent = destination + .parent() + .context("Restore destination has no parent")?; + fs::create_dir_all(parent)?; + let temp = tempfile::Builder::new() + .prefix(".restore-") + .tempdir_in(parent)?; + let decrypted = temp.path().join("stack.tar.gz"); + let source = if archive.extension().is_some_and(|suffix| suffix == "gpg") { + process::run( + Command::new("gpg") + .args(["--no-options", "--output"]) + .arg(&decrypted) + .arg("--decrypt") + .arg(archive), + )?; + &decrypted + } else { + archive + }; + let stage = temp.path().join("stack"); + files::extract(source, &stage)?; + let manifest = files::verify(&stage)?; + ensure!( + manifest.kind == "backup" || manifest.kind == "release", + "Unknown archive kind" + ); + let project = Project::at(&stage)?; + let mut settings = project.settings()?; + settings.mode = Mode::Local; + settings.project = format!( + "stack-{}", + files::hash_bytes(destination.as_os_str().as_encoded_bytes()) + .chars() + .take(10) + .collect::() + ); + project.save(&settings)?; + if stage.join(".git").is_dir() { + process::capture( + Command::new("git") + .arg("-C") + .arg(&stage) + .args(["remote", "set-url", "origin"]) + .arg(destination.join("var/data/repos/stack")), + )?; + } + fs::rename(&stage, destination)?; + process::line(format_args!( + "Restored and verified {}. No services started.", + destination.display() + )) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use anyhow::Result; + + use crate::files; + use crate::project::{Mode, Project}; + + #[test] + fn restore_is_local_and_refuses_an_existing_destination() -> Result<()> { + let workspace = tempfile::tempdir()?; + let source = workspace.path().join("source"); + files::private_dir(&source)?; + fs::write(source.join(".env"), "SEARXNG_SECRET=private\n")?; + let project = Project::at(&source)?; + let mut settings = project.settings()?; + settings.mode = Mode::Production; + project.save(&settings)?; + files::manifest(&source, "backup")?; + let archive = workspace.path().join("backup.tar.gz"); + files::pack(&source, &archive)?; + let destination = workspace.path().join("restored"); + super::restore(&archive, &destination)?; + assert_eq!(Project::at(&destination)?.settings()?.mode, Mode::Local); + assert_eq!( + fs::read_to_string(destination.join(".env"))?, + "SEARXNG_SECRET=private\n" + ); + assert!(super::restore(&archive, &destination).is_err()); + Ok(()) + } + + #[test] + fn corrupt_restore_never_publishes_a_destination() -> Result<()> { + let workspace = tempfile::tempdir()?; + let source = workspace.path().join("source"); + files::private_dir(&source)?; + fs::write(source.join(".env"), "original")?; + files::manifest(&source, "backup")?; + fs::write(source.join(".env"), "tampered")?; + let archive = workspace.path().join("backup.tar.gz"); + files::pack(&source, &archive)?; + let destination = workspace.path().join("restored"); + assert!(super::restore(&archive, &destination).is_err()); + assert!(!destination.exists()); + Ok(()) + } +} diff --git a/src/files.rs b/src/files.rs new file mode 100644 index 0000000..b939311 --- /dev/null +++ b/src/files.rs @@ -0,0 +1,336 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::collections::BTreeMap; +use std::fs::{self, File, OpenOptions, Permissions}; +use std::io::{Read, Write}; +use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt}; +use std::path::{Component, Path, PathBuf}; + +use anyhow::{Context, Result, bail, ensure}; +use flate2::Compression; +use flate2::read::GzDecoder; +use flate2::write::GzEncoder; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use walkdir::WalkDir; + +pub(crate) const SOURCE: &[&str] = &[ + "Cargo.toml", + "Cargo.lock", + "rust-toolchain.toml", + ".cargo", + ".gitignore", + ".forgejo", + "src", + "tests", + "crates", + "infra", + "web", + "docs", + "README.txt", + "LICENSE", + "deny.toml", + "about.toml", + "about.hbs", + ".env.example", +]; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Manifest { + pub(crate) format: u32, + pub(crate) kind: String, + pub(crate) version: String, + pub(crate) created_utc: String, + pub(crate) files: BTreeMap, +} + +pub(crate) fn private_dir(path: &Path) -> Result<()> { + fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(path)?; + fs::set_permissions(path, Permissions::from_mode(0o700))?; + Ok(()) +} + +pub(crate) fn write(path: &Path, data: &[u8], mode: u32) -> Result<()> { + let parent = path.parent().context("File has no parent directory")?; + fs::create_dir_all(parent)?; + let mut pending = tempfile::NamedTempFile::new_in(parent)?; + pending + .as_file_mut() + .set_permissions(Permissions::from_mode(mode))?; + pending.write_all(data)?; + pending.as_file().sync_all()?; + pending.persist(path).map_err(|error| error.error)?; + Ok(()) +} + +pub(crate) fn hash_bytes(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +pub(crate) fn hash_file(path: &Path) -> Result { + let mut file = File::open(path).with_context(|| format!("Cannot read {}", path.display()))?; + let mut digest = Sha256::new(); + let mut buffer = [0; 8192]; + loop { + let length = file.read(&mut buffer)?; + if length == 0 { + break; + } + digest.update(buffer.get(..length).context("Invalid read length")?); + } + Ok(hex::encode(digest.finalize())) +} + +pub(crate) fn relative(path: &Path, base: &Path) -> Result { + Ok(path + .strip_prefix(base)? + .to_str() + .context("Paths must be UTF-8")? + .to_owned()) +} + +pub(crate) fn regular_tree(path: &Path) -> Result<()> { + for item in WalkDir::new(path).follow_links(false) { + let entry = item?; + ensure!( + entry.file_type().is_dir() || entry.file_type().is_file(), + "Links and special files are not accepted: {}", + entry.path().display() + ); + } + Ok(()) +} + +pub(crate) fn copy_tree(source: &Path, destination: &Path) -> Result<()> { + regular_tree(source)?; + if source.is_file() { + let parent = destination.parent().context("File has no parent")?; + fs::create_dir_all(parent)?; + fs::copy(source, destination)?; + return Ok(()); + } + for item in WalkDir::new(source).follow_links(false) { + let entry = item?; + let target = destination.join(entry.path().strip_prefix(source)?); + if entry.file_type().is_dir() { + fs::create_dir_all(&target)?; + fs::set_permissions(&target, entry.metadata()?.permissions())?; + } else { + let parent = target.parent().context("File has no parent")?; + fs::create_dir_all(parent)?; + fs::copy(entry.path(), &target)?; + } + } + Ok(()) +} + +pub(crate) fn copy_source(root: &Path, destination: &Path) -> Result<()> { + for name in SOURCE { + let source = root.join(name); + if source.exists() { + copy_tree(&source, &destination.join(name))?; + } + } + Ok(()) +} + +pub(crate) fn inventory(root: &Path) -> Result> { + let mut files = BTreeMap::new(); + for item in WalkDir::new(root).follow_links(false) { + let entry = item?; + ensure!(!entry.file_type().is_symlink(), "Archive contains a link"); + if entry.file_type().is_file() { + let name = relative(entry.path(), root)?; + if name != "manifest.json" { + files.insert(name, hash_file(entry.path())?); + } + } + } + Ok(files) +} + +pub(crate) fn manifest(root: &Path, kind: &str) -> Result<()> { + let record = Manifest { + format: 2, + kind: kind.to_owned(), + version: env!("CARGO_PKG_VERSION").to_owned(), + created_utc: chrono::Utc::now().to_rfc3339(), + files: inventory(root)?, + }; + write( + &root.join("manifest.json"), + &serde_json::to_vec_pretty(&record)?, + 0o600, + ) +} + +pub(crate) fn verify(root: &Path) -> Result { + let record: Manifest = serde_json::from_reader(File::open(root.join("manifest.json"))?)?; + ensure!( + record.format == 2, + "Unsupported export format; legacy Python exports retain their embedded recovery helper" + ); + ensure!( + inventory(root)? == record.files, + "Archive file list or checksums do not match the manifest" + ); + Ok(record) +} + +pub(crate) fn pack(root: &Path, archive: &Path) -> Result<()> { + regular_tree(root)?; + let file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(archive)?; + let encoder = GzEncoder::new(file, Compression::new(3)); + let mut builder = tar::Builder::new(encoder); + builder.follow_symlinks(false); + builder.append_dir_all("stack", root)?; + builder.into_inner()?.finish()?.sync_all()?; + Ok(()) +} + +pub(crate) fn member_path(path: &Path, destination: &Path) -> Result { + let mut parts = path.components(); + ensure!( + parts.next() == Some(Component::Normal("stack".as_ref())), + "Archive must have a stack root" + ); + let mut target = destination.to_path_buf(); + for part in parts { + match part { + Component::Normal(name) => target.push(name), + _ => bail!("Unsafe archive path: {}", path.display()), + } + } + Ok(target) +} + +pub(crate) fn extract(archive: &Path, destination: &Path) -> Result<()> { + private_dir(destination)?; + let mut reader = tar::Archive::new(GzDecoder::new(File::open(archive)?)); + let mut directories = Vec::new(); + for item in reader.entries()? { + let mut entry = item?; + let kind = entry.header().entry_type(); + ensure!( + kind.is_dir() || kind.is_file(), + "Archive links and special files are forbidden" + ); + let target = member_path(&entry.path()?, destination)?; + let mode = entry.header().mode()? & 0o777; + if kind.is_dir() { + fs::create_dir_all(&target)?; + directories.push((target, mode | 0o700)); + } else { + let parent = target.parent().context("File has no parent")?; + fs::create_dir_all(parent)?; + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .mode(mode) + .open(&target)?; + std::io::copy(&mut entry, &mut output)?; + output.sync_all()?; + } + } + for (path, mode) in directories.into_iter().rev() { + fs::set_permissions(path, Permissions::from_mode(mode))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::Path; + + use anyhow::Result; + + use super::{copy_source, extract, manifest, member_path, pack, verify}; + + #[test] + fn rejects_paths_outside_archive_root() { + let destination = Path::new("/restore"); + for name in ["/etc/passwd", "../escape", "stack/../escape", "other/file"] { + assert!(member_path(Path::new(name), destination).is_err()); + } + assert_eq!( + member_path(Path::new("stack/web/index.html"), destination).ok(), + Some(destination.join("web/index.html")) + ); + } + + #[test] + fn detects_modified_and_extra_files() -> Result<()> { + let root = tempfile::tempdir()?; + fs::write(root.path().join("public.txt"), "original")?; + manifest(root.path(), "release")?; + assert!(verify(root.path()).is_ok()); + fs::write(root.path().join("public.txt"), "changed")?; + assert!(verify(root.path()).is_err()); + fs::write(root.path().join("public.txt"), "original")?; + fs::write(root.path().join("unexpected.txt"), "extra")?; + assert!(verify(root.path()).is_err()); + Ok(()) + } + + #[test] + fn source_export_excludes_secrets_and_runtime() -> Result<()> { + let source = tempfile::tempdir()?; + let target = tempfile::tempdir()?; + fs::write(source.path().join("Cargo.toml"), "source")?; + fs::write(source.path().join(".env"), "private")?; + fs::create_dir(source.path().join("var"))?; + fs::write(source.path().join("var/private.key"), "private")?; + copy_source(source.path(), target.path())?; + assert!(target.path().join("Cargo.toml").is_file()); + assert!(!target.path().join(".env").exists()); + assert!(!target.path().join("var").exists()); + Ok(()) + } + + #[test] + fn archives_round_trip_without_overwriting() -> Result<()> { + let workspace = tempfile::tempdir()?; + let source = workspace.path().join("source"); + fs::create_dir(&source)?; + fs::write(source.join(".env"), "secret")?; + manifest(&source, "backup")?; + let archive = workspace.path().join("backup.tar.gz"); + pack(&source, &archive)?; + let restored = workspace.path().join("restored"); + extract(&archive, &restored)?; + assert!(verify(&restored).is_ok()); + assert_eq!(fs::read_to_string(restored.join(".env"))?, "secret"); + assert!(extract(&archive, &restored).is_err()); + Ok(()) + } + + #[test] + fn rejects_symbolic_links() -> Result<()> { + let root = tempfile::tempdir()?; + std::os::unix::fs::symlink("/etc/passwd", root.path().join("link"))?; + assert!(super::regular_tree(root.path()).is_err()); + Ok(()) + } +} diff --git a/src/images.rs b/src/images.rs new file mode 100644 index 0000000..11daaea --- /dev/null +++ b/src/images.rs @@ -0,0 +1,215 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::collections::BTreeMap; +use std::fs::{self, File}; +use std::path::Path; + +use anyhow::{Context, Result, ensure}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::project::{Mode, Project, Settings, field}; +use crate::{files, process}; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Image { + reference: String, + architecture: String, + layers: Value, + id: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ImageLock { + format: u32, + platform: String, + services: BTreeMap, +} + +fn inspect(project: &Project, name: &str) -> Result { + let records: Vec = serde_json::from_slice( + &process::capture(project.command("podman")?.args(["image", "inspect", name]))?.stdout, + )?; + records + .into_iter() + .next() + .context("Docker returned no image") +} + +pub(crate) fn save(project: &Project, containers: &[Value], destination: &Path) -> Result<()> { + fs::create_dir_all(destination)?; + let platform = project.platform()?; + let mut services = BTreeMap::new(); + let mut new_tags = Vec::new(); + let result = save_inner( + project, + containers, + destination, + &mut services, + &mut new_tags, + ); + for tag in new_tags { + if let Err(error) = process::capture(project.command("podman")?.args(["image", "rm", &tag])) + { + process::error(format_args!( + "Could not remove temporary image tag {tag}: {error:#}" + ))?; + } + } + result?; + let lock = ImageLock { + format: 2, + platform, + services, + }; + files::write( + &destination.join("image-lock.json"), + &serde_json::to_vec_pretty(&lock)?, + 0o600, + ) +} + +fn save_inner( + project: &Project, + containers: &[Value], + destination: &Path, + services: &mut BTreeMap, + new_tags: &mut Vec, +) -> Result<()> { + for container in containers { + let service = field(container, &["Config", "Labels", "io.stack.service"])? + .as_str() + .context("Missing service label")?; + let id = field(container, &["Image"])? + .as_str() + .context("Missing image ID")?; + let suffix: String = id.trim_start_matches("sha256:").chars().take(16).collect(); + let alias = format!("localhost/stack-export-{service}:{suffix}"); + if !process::available( + project + .command("podman")? + .args(["image", "inspect", &alias]), + ) { + process::capture( + project + .command("podman")? + .args(["image", "tag", id, &alias]), + )?; + new_tags.push(alias.clone()); + } + let actual = inspect(project, &alias)?; + services.insert( + service.to_owned(), + Image { + reference: alias, + architecture: field(&actual, &["Architecture"])? + .as_str() + .context("Missing architecture")? + .to_owned(), + layers: field(&actual, &["RootFS", "Layers"])?.clone(), + id: field(&actual, &["Id"])? + .as_str() + .context("Missing image ID")? + .trim_start_matches("sha256:") + .to_owned(), + }, + ); + } + let mut command = project.command("podman")?; + command + .args([ + "save", + "--multi-image-archive", + "--format", + "docker-archive", + "--output", + ]) + .arg(destination.join("images.tar")); + for image in services.values() { + command.arg(&image.reference); + } + process::run(&mut command) +} + +pub(crate) fn prepare(project: &Project, settings: &mut Settings) -> Result<()> { + let path = project.root.join("var/image-lock.json"); + if settings.online || !path.is_file() { + return Ok(()); + } + let lock: ImageLock = serde_json::from_reader(File::open(path)?)?; + ensure!(lock.format == 2, "Unsupported image lock format"); + let platform = project.platform()?; + if platform.trim() != lock.platform { + ensure!( + settings.mode == Mode::Local, + "Release images target {}, but this server is {}; build a native release locally", + lock.platform, + platform.trim() + ); + settings.online = true; + return process::line(format_args!( + "Using native images/builds for {}", + platform.trim() + )); + } + let missing = lock.services.values().any(|item| { + project.command("podman").is_ok_and(|mut command| { + !process::available(command.args(["image", "inspect", &item.reference])) + }) + }); + if missing { + let archive = project.root.join("var/images.tar"); + ensure!( + archive.is_file(), + "Release image archive is missing; deploy a complete release" + ); + process::run( + project + .command("podman")? + .args(["image", "load", "--input"]) + .arg(archive), + )?; + } + for (service, image) in lock.services { + let actual = inspect(project, &image.reference)?; + ensure!( + field(&actual, &["Architecture"])?.as_str() == Some(image.architecture.as_str()) + && field(&actual, &["RootFS", "Layers"])? == &image.layers + && field(&actual, &["Id"])? + .as_str() + .context("Missing image ID")? + .trim_start_matches("sha256:") + == image.id, + "Loaded image does not match its content lock: {service}" + ); + } + Ok(()) +} + +pub(crate) fn configured(project: &Project, settings: &Settings) -> Result> { + let config = project.configuration(settings)?; + let mut records = Vec::new(); + for (service, value) in config.services { + let actual = inspect(project, &value.image)?; + records.push(serde_json::json!({ + "Image": field(&actual, &["Id"])?, + "Config": {"Labels": {"io.stack.service": service}} + })); + } + Ok(records) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..ec8784e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,252 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +#![doc = "Rust commands for personal web infrastructure."] + +pub(crate) mod archive; +pub(crate) mod files; +pub(crate) mod images; +pub(crate) mod process; +pub(crate) mod project; +pub(crate) mod release; + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use anyhow::Result; +use clap::{Parser, Subcommand}; + +use crate::project::{Mode, Project}; + +#[derive(Debug, Parser)] +#[command( + name = "stack", + version, + about = "Develop, deploy, and back up the personal web stack" +)] +struct Cli { + #[arg( + long, + global = true, + help = "Application root; otherwise discovered from the working directory or binary" + )] + root: Option, + #[command(subcommand)] + command: Action, +} + +#[derive(Debug, Subcommand)] +enum Action { + #[command(about = "Start the local development services")] + Dev { + #[arg(long, default_value_t = 8080)] + port: u16, + }, + #[command(about = "Start or update services in the selected mode")] + Up { + #[arg(long, conflicts_with = "local")] + production: bool, + #[arg(long)] + local: bool, + #[arg(long)] + port: Option, + #[arg(long)] + online: bool, + }, + #[command(about = "Show service status")] + Status, + #[command(about = "Stop services while retaining data")] + Down, + #[command(about = "Create a GPG-encrypted application export")] + Backup { + destination: Option, + #[arg(long)] + include_images: bool, + #[arg(long, default_value = archive::RECIPIENT)] + recipient: String, + #[arg(long)] + key: Option, + }, + #[command( + about = "Decrypt, validate, and restore into a new directory without starting services" + )] + Restore { + archive: PathBuf, + destination: PathBuf, + }, + #[command(about = "Run formatting, strict lints, tests, documentation, and dependency checks")] + Check, + #[command(about = "Check and build a portable production release locally")] + Release { destination: Option }, + #[command(about = "Install a prebuilt release without compiling on the server")] + Activate { archive: PathBuf }, + #[command(about = "Generate the third-party license page with GNU license names")] + Licenses, +} + +fn main() -> ExitCode { + let arguments = match Cli::try_parse() { + Ok(arguments) => arguments, + Err(error) => { + let status = if error.use_stderr() { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + }; + drop(error.print()); + return status; + } + }; + match execute(arguments) { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + drop(process::error(format_args!("Error: {error:#}"))); + ExitCode::FAILURE + } + } +} + +fn execute(arguments: Cli) -> Result<()> { + if let Action::Restore { + archive, + destination, + } = &arguments.command + { + return archive::restore(&absolute(archive)?, &absolute(destination)?); + } + let project = Project::discover(arguments.root.as_deref())?; + let _operation = project.lock()?; + match arguments.command { + Action::Dev { port } => { + let mut settings = project.settings()?; + settings.mode = Mode::Local; + settings.port = port; + images::prepare(&project, &mut settings)?; + project.up(&settings) + } + Action::Up { + production, + local, + port, + online, + } => { + let mut settings = project.settings()?; + if production { + settings.mode = Mode::Production; + } + if local { + settings.mode = Mode::Local; + } + if let Some(selected_port) = port { + settings.port = selected_port; + } + if online { + settings.online = true; + } + images::prepare(&project, &mut settings)?; + project.up(&settings) + } + Action::Status => project.status(&project.settings()?), + Action::Down => project.down(&project.settings()?), + Action::Backup { + destination, + include_images, + recipient, + key, + } => { + let output = destination.map_or_else( + || Ok(project.root.join("var/exports")), + |path| absolute(&path), + )?; + archive::backup( + &project, + &output, + include_images, + &recipient, + key.as_deref(), + ) + } + Action::Release { destination } => { + let output = destination + .map_or_else(|| Ok(project.root.join("dist")), |path| absolute(&path))?; + release::create(&project, &output) + } + Action::Activate { archive } => release::activate(&absolute(&archive)?, &project), + Action::Check => check(&project), + Action::Licenses => licenses(&project), + Action::Restore { .. } => anyhow::bail!("Restore action was not handled"), + } +} + +fn absolute(path: &Path) -> Result { + Ok(if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir()?.join(path) + }) +} + +fn licenses(project: &Project) -> Result<()> { + process::run(project.command("cargo")?.args([ + "about", + "generate", + "about.hbs", + "--output-file", + "web/THIRD-PARTY-LICENSES.html", + ])) +} + +fn check(project: &Project) -> Result<()> { + process::run( + project + .command("cargo")? + .args(["fmt", "--all", "--", "--check"]), + )?; + process::run(project.command("cargo")?.args([ + "clippy", + "--locked", + "--workspace", + "--all-targets", + "--all-features", + "--", + "-D", + "warnings", + ]))?; + process::run(project.command("cargo")?.args([ + "test", + "--locked", + "--workspace", + "--all-targets", + "--all-features", + ]))?; + process::run( + project + .command("cargo")? + .env("RUSTDOCFLAGS", "-D warnings") + .args([ + "doc", + "--locked", + "--workspace", + "--no-deps", + "--document-private-items", + ]), + )?; + process::run( + project + .command("cargo")? + .args(["deny", "--all-features", "check"]), + )?; + licenses(project) +} diff --git a/src/process.rs b/src/process.rs new file mode 100644 index 0000000..eb48bc8 --- /dev/null +++ b/src/process.rs @@ -0,0 +1,70 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::fmt; +use std::io::{self, Write}; +use std::process::{Command, Output}; + +use anyhow::{Context, Result, bail}; + +pub(crate) fn capture(command: &mut Command) -> Result { + let output = command.output().with_context(|| { + format!( + "Could not start {}", + command.get_program().to_string_lossy() + ) + })?; + if !output.status.success() { + bail!( + "{} failed ({}): {}", + command.get_program().to_string_lossy(), + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ); + } + Ok(output) +} + +pub(crate) fn text(command: &mut Command) -> Result { + String::from_utf8(capture(command)?.stdout).context("Command returned non-UTF-8 output") +} + +pub(crate) fn run(command: &mut Command) -> Result<()> { + let status = command.status().with_context(|| { + format!( + "Could not start {}", + command.get_program().to_string_lossy() + ) + })?; + if !status.success() { + bail!( + "{} failed ({status})", + command.get_program().to_string_lossy() + ); + } + Ok(()) +} + +pub(crate) fn available(command: &mut Command) -> bool { + command.output().is_ok_and(|output| output.status.success()) +} + +pub(crate) fn line(message: fmt::Arguments<'_>) -> Result<()> { + writeln!(io::stdout().lock(), "{message}").context("Could not write command output") +} + +pub(crate) fn error(message: fmt::Arguments<'_>) -> io::Result<()> { + writeln!(io::stderr().lock(), "{message}") +} diff --git a/src/project.rs b/src/project.rs new file mode 100644 index 0000000..bb96fe0 --- /dev/null +++ b/src/project.rs @@ -0,0 +1,535 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::collections::BTreeMap; +use std::env; +use std::fs::{self, File, OpenOptions, Permissions}; +use std::io::Read; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::{Component, Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use anyhow::{Context, Result, bail, ensure}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::{files, process}; + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum Mode { + #[default] + Local, + Production, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Settings { + pub(crate) mode: Mode, + pub(crate) port: u16, + pub(crate) project: String, + #[serde(default)] + pub(crate) online: bool, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Configuration { + pub(crate) format: u32, + pub(crate) services: BTreeMap, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Service { + pub(crate) image: String, + pub(crate) build: Option, + #[serde(default)] + gateway: bool, + #[serde(default)] + environment: Vec, + #[serde(default)] + drop_capabilities: bool, + health: Vec, + mounts: Vec, + #[serde(default)] + tmpfs: Vec, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Mount { + source: String, + target: String, + #[serde(default)] + read_only: bool, +} + +#[derive(Debug)] +pub(crate) struct Project { + pub(crate) root: PathBuf, +} + +impl Project { + pub(crate) fn discover(explicit: Option<&Path>) -> Result { + if let Some(path) = explicit { + return Self::at(path); + } + let current = env::current_dir()?; + for candidate in current.ancestors() { + if candidate.join("infra/services.toml").is_file() { + return Self::at(candidate); + } + } + let executable = env::current_exe()?; + for candidate in executable.ancestors().skip(1).take(3) { + if candidate.join("infra/services.toml").is_file() { + return Self::at(candidate); + } + } + bail!("Run from the stack directory or supply --root PATH") + } + + pub(crate) fn at(path: &Path) -> Result { + let root = if path.is_absolute() { + path.to_path_buf() + } else { + env::current_dir()?.join(path) + }; + Ok(Self { root }) + } + + pub(crate) fn settings(&self) -> Result { + let path = self.root.join("var/state.json"); + if path.exists() { + return Ok(serde_json::from_reader(File::open(path)?)?); + } + Ok(Settings { + mode: Mode::Local, + port: 8080, + project: format!( + "stack-{}", + files::hash_bytes(self.root.as_os_str().as_encoded_bytes()) + .chars() + .take(10) + .collect::() + ), + online: false, + }) + } + + pub(crate) fn save(&self, settings: &Settings) -> Result<()> { + files::write( + &self.root.join("var/state.json"), + &serde_json::to_vec_pretty(settings)?, + 0o600, + ) + } + + pub(crate) fn lock(&self) -> Result { + files::private_dir(&self.root.join("var"))?; + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(0o600) + .open(self.root.join("var/operation.lock"))?; + file.try_lock() + .context("Another stack operation is already running")?; + Ok(file) + } + + pub(crate) fn command(&self, name: &str) -> Result { + let mut command = Command::new(name); + command.current_dir(&self.root); + if let Some(tools) = self + .root + .ancestors() + .map(|p| p.join(".tools")) + .find(|p| p.join("bin/cargo-deny").is_file()) + { + let mut paths = vec![tools.join("bin")]; + if let Some(inherited) = env::var_os("PATH") { + paths.extend(env::split_paths(&inherited)); + } + command.env("PATH", env::join_paths(paths)?); + } + if name == "podman" { + command + .arg("--root") + .arg(self.root.join("var/podman/storage")) + .arg("--runroot") + .arg(self.root.join("var/podman/run")); + } + Ok(command) + } + + pub(crate) fn platform(&self) -> Result { + let info: Value = serde_json::from_slice( + &process::capture(self.command("podman")?.args(["info", "--format", "json"]))?.stdout, + )?; + Ok(format!( + "{}/{}", + field(&info, &["host", "os"])? + .as_str() + .context("Missing host OS")?, + field(&info, &["host", "arch"])? + .as_str() + .context("Missing host architecture")? + )) + } + + pub(crate) fn configuration(&self, settings: &Settings) -> Result { + let mut config: Configuration = + toml::from_str(&fs::read_to_string(self.root.join("infra/services.toml"))?)?; + ensure!( + config.format == 1 && config.services.contains_key("caddy"), + "Invalid services configuration" + ); + if self.root.join("var/image-lock.json").is_file() && !settings.online { + let lock: Value = + serde_json::from_reader(File::open(self.root.join("var/image-lock.json"))?)?; + for (name, service) in &mut config.services { + field(&lock, &["services", name, "reference"])? + .as_str() + .context("Invalid image lock")? + .clone_into(&mut service.image); + } + } + Ok(config) + } + + pub(crate) fn initialize(&self, settings: &Settings) -> Result<()> { + files::private_dir(&self.root.join("var/data"))?; + let env_file = self.root.join(".env"); + if !env_file.exists() { + let mut random = [0; 32]; + File::open("/dev/urandom")?.read_exact(&mut random)?; + let secret = hex::encode(random); + files::write( + &env_file, + format!("SEARXNG_SECRET={secret}\n").as_bytes(), + 0o600, + )?; + } + fs::set_permissions(&env_file, Permissions::from_mode(0o600))?; + for name in ["repos", "caddy/data", "caddy/config", "searxng"] { + fs::create_dir_all(self.root.join("var/data").join(name))?; + } + fs::set_permissions( + self.root.join("var/data/repos"), + Permissions::from_mode(0o755), + )?; + let repository = self.root.join("var/data/repos/stack"); + if !repository.exists() { + if self.root.join(".git").is_dir() { + process::capture( + self.command("git")? + .args(["clone", "--bare", "."]) + .arg(&repository), + )?; + } else { + process::capture( + self.command("git")? + .args(["init", "--bare", "--initial-branch=main"]) + .arg(&repository), + )?; + } + } + files::copy_tree( + &self.root.join("infra/searxng"), + &self.root.join("var/data/searxng"), + )?; + if settings.mode == Mode::Local { + self.local_site(settings.port)?; + } + Ok(()) + } + + fn local_site(&self, port: u16) -> Result<()> { + let generated = self.root.join("var/local/web"); + if generated.exists() { + files::regular_tree(&generated)?; + fs::remove_dir_all(&generated)?; + } + files::copy_tree(&self.root.join("web"), &generated)?; + let index = generated.join("index.html"); + let html = fs::read_to_string(&index)? + .replace("https://git.doan.luuduct.ai", "/git") + .replace("https://search.doan.luuduct.ai", "/search") + .replace("https://openpgpkey.luuduct.ai", ""); + files::write(&index, html.as_bytes(), 0o644)?; + let template = self.root.join("infra/containers/cgit/cgitrc.local"); + let content = fs::read_to_string(&template)? + .lines() + .map(|line| { + if line.starts_with("clone-prefix=") { + format!("clone-prefix=http://localhost:{port}/git") + } else { + line.to_owned() + } + }) + .collect::>() + .join("\n"); + files::write( + &self.root.join("var/local/cgitrc"), + format!("{content}\n").as_bytes(), + 0o644, + ) + } + + pub(crate) fn container_name(settings: &Settings, service: &str) -> String { + format!("{}-{service}", settings.project) + } + + pub(crate) fn inspect_containers(&self, settings: &Settings) -> Result> { + let output = process::text(self.command("podman")?.args([ + "ps", + "--all", + "--quiet", + "--filter", + &format!("label=io.stack.project={}", settings.project), + ]))?; + let ids: Vec<_> = output.split_whitespace().collect(); + ensure!(!ids.is_empty(), "No stack containers found"); + Ok(serde_json::from_slice( + &process::capture(self.command("podman")?.arg("inspect").args(ids))?.stdout, + )?) + } + + pub(crate) fn up(&self, settings: &Settings) -> Result<()> { + ensure!( + (1024..=65_534).contains(&settings.port), + "Local port must be between 1024 and 65534" + ); + self.initialize(settings)?; + self.save(settings)?; + let config = self.configuration(settings)?; + let network = format!("{}-net", settings.project); + if !process::available( + self.command("podman")? + .args(["network", "exists", &network]), + ) { + process::capture( + self.command("podman")? + .args(["network", "create", &network]), + )?; + } + let environment: BTreeMap = + dotenvy::from_path_iter(self.root.join(".env"))? + .collect::>()?; + for (name, service) in config + .services + .iter() + .filter(|(_, service)| !service.gateway) + .chain( + config + .services + .iter() + .filter(|(_, service)| service.gateway), + ) + { + self.ensure_image(settings, service)?; + self.start_service(settings, &network, name, service, &environment)?; + self.wait_service(settings, name, service)?; + } + process::line(format_args!( + "Running {}", + if settings.mode == Mode::Production { + "https://doan.luuduct.ai".to_owned() + } else { + format!("http://localhost:{}", settings.port) + } + )) + } + + pub(crate) fn ensure_image(&self, settings: &Settings, service: &Service) -> Result<()> { + if settings.mode == Mode::Local + && (settings.online || !self.root.join("var/image-lock.json").exists()) + && let Some(context) = &service.build + { + return process::run( + self.command("podman")? + .args(["build", "--tag", &service.image]) + .arg(self.path(context)?), + ); + } + if process::available( + self.command("podman")? + .args(["image", "exists", &service.image]), + ) { + return Ok(()); + } + ensure!( + settings.mode == Mode::Local, + "Production images must be built and deployed from the development machine" + ); + process::run(self.command("podman")?.args(["pull", &service.image])) + } + + fn start_service( + &self, + settings: &Settings, + network: &str, + name: &str, + service: &Service, + environment: &BTreeMap, + ) -> Result<()> { + let container = Self::container_name(settings, name); + let mut command = self.command("podman")?; + command.args([ + "run", + "--detach", + "--replace", + "--pull", + "never", + "--name", + &container, + "--network", + network, + "--network-alias", + name, + "--restart", + "unless-stopped", + "--label", + &format!("io.stack.project={}", settings.project), + "--label", + &format!("io.stack.service={name}"), + "--security-opt", + "no-new-privileges", + ]); + if service.drop_capabilities { + command.args(["--cap-drop", "ALL"]); + } + if service.gateway { + let ports = if settings.mode == Mode::Local { + let tls = settings.port.checked_add(1).context("Port overflow")?; + [ + format!("127.0.0.1:{}:80", settings.port), + format!("127.0.0.1:{tls}:443"), + format!("127.0.0.1:{tls}:443/udp"), + ] + } else { + [ + "80:80".to_owned(), + "443:443".to_owned(), + "443:443/udp".to_owned(), + ] + }; + for port in ports { + command.args(["--publish", &port]); + } + } + for key in &service.environment { + command.arg("--env").arg(key).env( + key, + environment + .get(key) + .with_context(|| format!("Missing secret {key} in .env"))?, + ); + } + for mount in &service.mounts { + let source = match mount.source.as_str() { + "@caddy" => self.root.join(if settings.mode == Mode::Local { + "infra/Caddyfile.local" + } else { + "infra/Caddyfile" + }), + "@web" => self.root.join(if settings.mode == Mode::Local { + "var/local/web" + } else { + "web" + }), + "@cgit" => self.root.join(if settings.mode == Mode::Local { + "var/local/cgitrc" + } else { + "infra/containers/cgit/cgitrc" + }), + other => self.path(other)?, + }; + let mode = if mount.read_only { "ro,z" } else { "rw,z" }; + command + .arg("--volume") + .arg(format!("{}:{}:{mode}", source.display(), mount.target)); + } + for mount in &service.tmpfs { + command.args(["--tmpfs", mount]); + } + process::capture(command.arg(&service.image))?; + Ok(()) + } + + fn wait_service(&self, settings: &Settings, name: &str, service: &Service) -> Result<()> { + let container = Self::container_name(settings, name); + for _attempt in 0..120 { + if process::available( + self.command("podman")? + .args(["exec", &container]) + .args(&service.health), + ) { + return Ok(()); + } + std::thread::sleep(Duration::from_millis(500)); + } + bail!("Service did not become ready: {name}; inspect its Podman logs") + } + + pub(crate) fn status(&self, settings: &Settings) -> Result<()> { + process::run(self.command("podman")?.args([ + "ps", + "--all", + "--filter", + &format!("label=io.stack.project={}", settings.project), + ])) + } + + pub(crate) fn down(&self, settings: &Settings) -> Result<()> { + for name in self.configuration(settings)?.services.keys() { + process::capture(self.command("podman")?.args([ + "rm", + "--force", + "--ignore", + &Self::container_name(settings, name), + ]))?; + } + process::capture(self.command("podman")?.args([ + "network", + "rm", + "--ignore", + &format!("{}-net", settings.project), + ]))?; + Ok(()) + } + + fn path(&self, relative: &str) -> Result { + let path = Path::new(relative); + ensure!( + path.components() + .all(|part| matches!(part, Component::Normal(_))), + "Service paths must stay inside stack" + ); + Ok(self.root.join(path)) + } +} + +pub(crate) fn field<'a>(value: &'a Value, path: &[&str]) -> Result<&'a Value> { + path.iter().try_fold(value, |node, key| { + node.get(key) + .with_context(|| format!("Missing JSON field {key}")) + }) +} diff --git a/src/release.rs b/src/release.rs new file mode 100644 index 0000000..88b4093 --- /dev/null +++ b/src/release.rs @@ -0,0 +1,131 @@ +// Copyright (C) 2026 Doan Luu Duc Tai +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result, ensure}; + +use crate::project::{Mode, Project}; +use crate::{files, images, process}; + +pub(crate) fn create(project: &Project, destination: &Path) -> Result<()> { + crate::check(project)?; + process::run(project.command("cargo")?.args([ + "build", + "--locked", + "--release", + "--target", + "x86_64-unknown-linux-musl", + ]))?; + files::write( + &project.root.join("bin/stack"), + &fs::read( + project + .root + .join("target/x86_64-unknown-linux-musl/release/stack"), + )?, + 0o755, + )?; + let mut settings = project.settings()?; + settings.mode = Mode::Local; + settings.online = true; + for service in project.configuration(&settings)?.services.values() { + project.ensure_image(&settings, service)?; + } + files::private_dir(destination)?; + let temp = tempfile::Builder::new() + .prefix(".release-") + .tempdir_in(destination)?; + let stage = temp.path().join("stack"); + files::private_dir(&stage)?; + files::copy_source(&project.root, &stage)?; + fs::create_dir_all(stage.join("bin"))?; + fs::copy( + project + .root + .join("target/x86_64-unknown-linux-musl/release/stack"), + stage.join("bin/stack"), + )?; + images::save( + project, + &images::configured(project, &settings)?, + &stage.join("var"), + )?; + files::manifest(&stage, "release")?; + let output = destination.join(format!( + "stack-release-{}.tar.gz", + chrono::Utc::now().format("%Y%m%dT%H%M%SZ") + )); + files::pack(&stage, &output)?; + let filename = output + .file_name() + .context("Missing archive name")? + .to_string_lossy(); + files::write( + &output.with_extension("gz.sha256"), + format!("{} {filename}\n", files::hash_file(&output)?).as_bytes(), + 0o600, + )?; + process::line(format_args!("Release: {}", output.display())) +} + +pub(crate) fn activate(archive: &Path, project: &Project) -> Result<()> { + let temp = tempfile::Builder::new() + .prefix(".activate-") + .tempdir_in(&project.root)?; + let stage = temp.path().join("stack"); + files::extract(archive, &stage)?; + ensure!( + files::verify(&stage)?.kind == "release", + "Activation requires a release archive" + ); + let pending = Project::at(&stage)?; + let mut settings = project.settings()?; + settings.mode = Mode::Production; + settings.online = false; + let lock = stage.join("var/image-lock.json"); + let info: serde_json::Value = serde_json::from_slice(&fs::read(&lock)?)?; + ensure!( + info.get("platform").and_then(serde_json::Value::as_str) + == Some(project.platform()?.as_str()), + "Release architecture does not match the server" + ); + let _config = pending.configuration(&settings)?; + for name in files::SOURCE { + let source = stage.join(name); + if source.exists() { + let target = project.root.join(name); + if target.is_dir() { + files::regular_tree(&target)?; + fs::remove_dir_all(&target)?; + } + files::copy_tree(&source, &target)?; + } + } + files::write( + &project.root.join("bin/stack"), + &fs::read(stage.join("bin/stack"))?, + 0o755, + )?; + for name in ["images.tar", "image-lock.json"] { + files::copy_tree( + &stage.join("var").join(name), + &project.root.join("var").join(name), + )?; + } + images::prepare(project, &mut settings)?; + project.up(&settings) +} diff --git a/web/.well-known/openpgpkey/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp b/web/.well-known/openpgpkey/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp new file mode 100644 index 0000000..1b90e81 Binary files /dev/null and b/web/.well-known/openpgpkey/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp differ diff --git a/web/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp b/web/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp new file mode 100644 index 0000000..1b90e81 Binary files /dev/null and b/web/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp differ diff --git a/web/.well-known/openpgpkey/luuduct.ai/policy b/web/.well-known/openpgpkey/luuduct.ai/policy new file mode 100644 index 0000000..e69de29 diff --git a/web/.well-known/openpgpkey/policy b/web/.well-known/openpgpkey/policy new file mode 100644 index 0000000..e69de29 diff --git a/web/THIRD-PARTY-LICENSES.html b/web/THIRD-PARTY-LICENSES.html new file mode 100644 index 0000000..cd129d9 --- /dev/null +++ b/web/THIRD-PARTY-LICENSES.html @@ -0,0 +1,961 @@ + + + + + + + + Stack — third-party licenses + + + + +

Third-party licenses for Stack

+

Stack is covered by the GNU Affero General Public License, version 3 or later. +This page covers the Rust dependencies recorded in Cargo.lock. Supporting container +applications and their operating-system packages have their own notices.

+

Headings follow GNU's +license names where listed. SPDX identifiers remain available for exact identification. +The original license texts below are unchanged.

+ +
+

Expat License

+ +
# The MIT License (MIT)
+
+Copyright (c) 2014 Santiago Lapresta and contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+ + +
Copyright (c) 2013-2014 The Rust Project Developers.
+Copyright (c) 2015-2020 The rust-hex Developers
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+ + +
Copyright (c) 2014 Alex Crichton
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
Copyright (c) 2014 Chris Wong
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
Copyright (c) 2014 The Rust Project Developers
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
Copyright (c) 2014-2026 Alex Crichton
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
Copyright (c) 2015 Steven Allen
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
Copyright (c) 2015 The Rust Project Developers
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
Copyright (c) 2016 Joe Wilm
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
Copyright (c) 2016-2026 The RustCrypto Project Developers
+Copyright (c) 2016 Artyom Pavlov
+Copyright (c) 2009-2013 Mozilla Foundation
+Copyright (c) 2006-2009 Graydon Hoare
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
Copyright (c) 2017-2025 RustCrypto Developers
+Copyright (c) 2017 Artyom Pavlov
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
Copyright (c) 2018 Josh Stone
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
Copyright (c) 2018-2025 The RustCrypto Project Developers
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
Copyright (c) 2018-2026 The rust-random Project Developers
+Copyright (c) 2014 The Rust Project Developers
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
Copyright (c) 2020-2026 The RustCrypto Project Developers
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
Copyright (c) 2021-2026 RustCrypto Developers
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
Copyright (c) 2022-2026 The RustCrypto Project Developers
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
Copyright (c) Individual contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+ + +
Copyright (c) The Rust Project Developers
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
Copyright (c) The tar-rs Project Contributors
+
+Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
MIT License
+
+Copyright (c) 2018 Sam Rijs, Alex Crichton and contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+ + +
MIT License
+
+Copyright (c) <year> <copyright holders>
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
+associated documentation files (the "Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
+following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial
+portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
+LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
+EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
+USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
MIT License
+
+Copyright (c) [2021] [Marvin Countryman]
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+ + +
MIT License
+
+Copyright 2013-2014 RAD Game Tools and Valve Software
+Copyright 2010-2014 Rich Geldreich and Tenacious Software LLC
+Copyright (c) 2017 Frommi
+Copyright (c) 2017-2024 oyvindln
+
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+ + +
MIT License
+
+Copyright 2013-2014 RAD Game Tools and Valve Software
+Copyright 2010-2014 Rich Geldreich and Tenacious Software LLC
+Copyright (c) 2017 Frommi
+Copyright (c) 2017-2024 oyvindln
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+ + +
Permission is hereby granted, free of charge, to any
+person obtaining a copy of this software and associated
+documentation files (the "Software"), to deal in the
+Software without restriction, including without
+limitation the rights to use, copy, modify, merge,
+publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software
+is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice
+shall be included in all copies or substantial portions
+of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
+ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
+TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
+SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
+IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
+DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
+
+ + +
The MIT License (MIT)
+
+Copyright (c) 2014 Paho Lurie-Gregg
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+ + +
The MIT License (MIT)
+
+Copyright (c) 2015 Andrew Gallant
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
+ + +
The MIT License (MIT)
+
+Copyright (c) 2015 Danny Guo
+Copyright (c) 2016 Titus Wormer <tituswormer@gmail.com>
+Copyright (c) 2018 Akash Kurdekar
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+
+
+ + +
The MIT License (MIT)
+
+Copyright (c) 2017 Andrew Gallant
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+
+
+

Unicode License v3

+ +
UNICODE LICENSE V3
+
+COPYRIGHT AND PERMISSION NOTICE
+
+Copyright © 1991-2023 Unicode, Inc.
+
+NOTICE TO USER: Carefully read the following legal agreement. BY
+DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
+SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
+TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
+DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of data files and any associated documentation (the "Data Files") or
+software and any associated documentation (the "Software") to deal in the
+Data Files or Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, and/or sell
+copies of the Data Files or Software, and to permit persons to whom the
+Data Files or Software are furnished to do so, provided that either (a)
+this copyright and permission notice appear with all copies of the Data
+Files or Software, or (b) this copyright and permission notice appear in
+associated Documentation.
+
+THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
+KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
+THIRD PARTY RIGHTS.
+
+IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
+BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
+OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
+WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
+ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
+FILES OR SOFTWARE.
+
+Except as contained in this notice, the name of a copyright holder shall
+not be used in advertising or otherwise to promote the sale, use or other
+dealings in these Data Files or Software without prior written
+authorization of the copyright holder.
+
+
+ + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..4d808a8 --- /dev/null +++ b/web/index.html @@ -0,0 +1,91 @@ + + + + + + + + Doan Luu Duc Tai + + + + + + +
+ +
[ JS-free ]
+ +
+ +
+
+
+Home  Services
+
+
++------------------------------------------------------------------------------+
+|                                                                              |
+|                               Doan Luu Duc Tai                               |
+|                                                                              |
+|                            Email: doan@luuduct.ai                            |
+|                GnuPG: 3346AA3150F8F16828BB0D14F8C66DF77AFECEA6               |
+|                                                                              |
+|                                 SimpleX Chat                                 |
+|                                                                              |
++------------------------------------------------------------------------------+
+
+
+
+
+Services
+________________________________________________________________________________
+
+
+git.doan.luuduct.ai
+* A cgit instance, functional without JS.
+
+    Personal Git repositories
+
+
+search.doan.luuduct.ai
+* A SearXNG instance, functional without JS.
+
+    Private search engine
+
+
+
+
+				
+ + + +
+
+ + + diff --git a/web/styles.css b/web/styles.css new file mode 100644 index 0000000..f2f158c --- /dev/null +++ b/web/styles.css @@ -0,0 +1,117 @@ +/* + * Copyright (C) 2026 Doan Luu Duc Tai + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +body { + overflow-y: scroll; + font: 16px monospace, monospace; + margin: 0; + padding: 0; +} + +pre { + margin: 0; + overflow-x: auto; +} + +a { + color: #06c; + text-decoration: none; +} + +.good { + color: #090; + font-weight: bold; +} + +.logo { + color: #a00; + text-decoration: none; +} + +.logo:hover { + text-decoration: none; +} + +.badge { + position: absolute; + top: 16px; + left: calc(50% + 280px); + color: #090; + font-weight: bold; + white-space: nowrap; +} + +#page { + display: table; + margin: 16px auto; + position: relative; +} + +#content { + max-width: 80ch; + width: 100%; +} + +.good a { + color: inherit; + text-decoration: underline; +} + +.good a { + text-decoration-style: wavy; +} + +@media (prefers-color-scheme: dark) { + body { + background: #000; + color: #fff; + } + + a { + color: #6cf; + } + + .logo { + color: #f33; + } + + .badge { + color: #0f0; + } + + .good { + color: #0f0; + } +} + +@media (max-width: 999px) { + body { + font-size: 1.94vw; + } + + .badge { + left: auto; + right: 16px; + } +} + +.source-footer { + text-align: right; + margin-top: 32px; + margin-bottom: 16px; + font-weight: bold; +} diff --git a/www/.well-known/openpgpkey/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp b/www/.well-known/openpgpkey/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp deleted file mode 100644 index f0fa994..0000000 Binary files a/www/.well-known/openpgpkey/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp and /dev/null differ diff --git a/www/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp b/www/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp deleted file mode 100644 index f0fa994..0000000 Binary files a/www/.well-known/openpgpkey/luuduct.ai/hu/t76q8xqkeassgy1kr38re1ppj6cpc1dp and /dev/null differ diff --git a/www/.well-known/openpgpkey/luuduct.ai/policy b/www/.well-known/openpgpkey/luuduct.ai/policy deleted file mode 100644 index e69de29..0000000 diff --git a/www/.well-known/openpgpkey/policy b/www/.well-known/openpgpkey/policy deleted file mode 100644 index e69de29..0000000 diff --git a/www/index.html b/www/index.html deleted file mode 100644 index 5911c41..0000000 --- a/www/index.html +++ /dev/null @@ -1,121 +0,0 @@ - - - - - - - - Doan Luu Duc Tai - - - - - - -
- -
[ JS-free ]
- -
- -
-
-
-Home  Projects  Services
-
-
-+------------------------------------------------------------------------------+
-|                                                                              |
-|                               Doan Luu Duc Tai                               |
-|                                                                              |
-|                            Email: doan@luuduct.ai                            |
-|                GnuPG: 8990FFC0F9D95FAA4C04520BADF3DE23F687C623               |
-|                                                                              |
-|                                 SimpleX Chat                                 |
-|                                    Signal                                    |
-|                                                                              |
-+------------------------------------------------------------------------------+
-
-
-
-
-Projects
-________________________________________________________________________________
-
-
-csprdg
-    Conjecturally Secure Pseudo-Random Document Generator
-
-
-csprsg
-    Conjecturally Secure Pseudo-Random Spreadsheet Generator
-
-
-fileshare
-    LAN File Server
-
-
-kanji-deck
-    personal kanji deck (you are better off using Anki ahaha)
-
-
-stack
-    Personal infrastructure stack.
-
-
-vfetch
-    neofetch but the ascii part is a video
-
-
-
-
-Services
-________________________________________________________________________________
-
-
-git.doan.luuduct.ai
-* A cgit instance, functional without JS.
-
-    Personal Git repositories
-
-
-search.doan.luuduct.ai
-* A SearXNG instance, functional without JS.
-
-    Private search engine
-
-
-
-
-				
- - - -
-
- - - diff --git a/www/styles.css b/www/styles.css deleted file mode 100644 index f2f158c..0000000 --- a/www/styles.css +++ /dev/null @@ -1,117 +0,0 @@ -/* - * Copyright (C) 2026 Doan Luu Duc Tai - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of the - * License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -body { - overflow-y: scroll; - font: 16px monospace, monospace; - margin: 0; - padding: 0; -} - -pre { - margin: 0; - overflow-x: auto; -} - -a { - color: #06c; - text-decoration: none; -} - -.good { - color: #090; - font-weight: bold; -} - -.logo { - color: #a00; - text-decoration: none; -} - -.logo:hover { - text-decoration: none; -} - -.badge { - position: absolute; - top: 16px; - left: calc(50% + 280px); - color: #090; - font-weight: bold; - white-space: nowrap; -} - -#page { - display: table; - margin: 16px auto; - position: relative; -} - -#content { - max-width: 80ch; - width: 100%; -} - -.good a { - color: inherit; - text-decoration: underline; -} - -.good a { - text-decoration-style: wavy; -} - -@media (prefers-color-scheme: dark) { - body { - background: #000; - color: #fff; - } - - a { - color: #6cf; - } - - .logo { - color: #f33; - } - - .badge { - color: #0f0; - } - - .good { - color: #0f0; - } -} - -@media (max-width: 999px) { - body { - font-size: 1.94vw; - } - - .badge { - left: auto; - right: 16px; - } -} - -.source-footer { - text-align: right; - margin-top: 32px; - margin-bottom: 16px; - font-weight: bold; -} -- cgit v1.2.3