From 3d7085358b8461241d42bacf12b909d2c82f32e2 Mon Sep 17 00:00:00 2001 From: Nikolay Govorov Date: Tue, 25 Aug 2026 23:07:48 +0100 Subject: Replace nginx to tiny rust server --- .dockerignore | 1 + .github/workflows/build.yml | 3 + .gitignore | 1 + Cargo.lock | 415 ++++++++++++++++++++++++++++++++++++ Cargo.toml | 27 +++ Dockerfile | 26 ++- README.md | 11 +- REUSE.toml | 1 + charts/gilti/README.md | 5 +- config/nginx.conf | 57 ----- crates/gilti/Cargo.toml | 24 +++ crates/gilti/src/cgi.rs | 230 ++++++++++++++++++++ crates/gilti/src/main.rs | 179 ++++++++++++++++ mise.lock | 4 + mise.toml | 1 + scripts/entrypoint.sh | 26 +-- tests/smoke.sh | 31 +++ 17 files changed, 956 insertions(+), 86 deletions(-) create mode 100644 Cargo.lock create mode 100644 Cargo.toml delete mode 100644 config/nginx.conf create mode 100644 crates/gilti/Cargo.toml create mode 100644 crates/gilti/src/cgi.rs create mode 100644 crates/gilti/src/main.rs diff --git a/.dockerignore b/.dockerignore index 4f50e86..351a45c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -5,3 +5,4 @@ .github .gilti-state dist +target diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 921d397..163f258 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,6 +29,9 @@ jobs: experimental: true install: true cache: true + - run: cargo fmt -- --check + - run: cargo test --locked + - run: cargo clippy --locked --all-targets -- -D warnings - run: shellcheck scripts/*.sh tests/*.sh - run: tests/chart.sh diff --git a/.gitignore b/.gitignore index 7222147..a395ced 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # SPDX-License-Identifier: AGPL-3.0-or-later /dist +/target /.gilti-state mise.local.toml mise.*.local.toml diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..687f5d8 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,415 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[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 = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gilti" +version = "0.1.0" +dependencies = [ + "axum", + "percent-encoding", + "tokio", + "tower", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[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.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[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 = "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 = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[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", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..eb6b195 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: 2026 Nikolay Govorov +# SPDX-License-Identifier: AGPL-3.0-or-later + +[workspace] +members = ["crates/*"] +resolver = "3" + +[workspace.package] +publish = false +edition = "2024" +version = "0.1.0" +license = "AGPL-3.0-or-later" +authors = ["Nikolay Govorov "] +homepage = "https://github.com/dimidiumlabs/gilti" +repository = "https://github.com/dimidiumlabs/gilti" + +[workspace.dependencies] +axum = { version = "0.8.4", default-features = false, features = ["http1", "tokio"] } +percent-encoding = "2.3.2" +tokio = { version = "1.44.2", features = ["io-util", "macros", "net", "process", "rt-multi-thread", "signal"] } +tower = { version = "0.5.2", features = ["util"] } + +[profile.release] +codegen-units = 1 +lto = "thin" +panic = "abort" +strip = true diff --git a/Dockerfile b/Dockerfile index 829cdf0..0443205 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,17 @@ ARG ALPINE_VERSION=3.22.1 ARG ALPINE_DIGEST=sha256:4bcff63911fcb4448bd4fdacec207030997caf25e9bea4045fa6c8c44de311d1 +FROM docker.io/library/alpine:${ALPINE_VERSION}@${ALPINE_DIGEST} AS builder + +RUN apk add --no-cache \ + cargo=1.87.0-r1 \ + rust=1.87.0-r1 + +WORKDIR /src +COPY Cargo.toml Cargo.lock ./ +COPY crates ./crates +RUN cargo build --locked --release --package gilti --bin gilti-httpd + FROM docker.io/library/alpine:${ALPINE_VERSION}@${ALPINE_DIGEST} ARG VERSION=dev @@ -19,14 +30,12 @@ LABEL org.opencontainers.image.title="Gilti" \ RUN apk add --no-cache \ cgit=1.2.3-r5 \ - fcgiwrap=1.1.0-r8 \ git=2.49.1-r0 \ gitolite=3.6.13-r1 \ - nginx=1.28.3-r7 \ + libgcc=14.2.0-r6 \ openssh-keygen=10.0_p1-r10 \ openssh-server=10.0_p1-r10 \ perl=5.40.4-r0 \ - spawn-fcgi=1.6.5-r4 \ su-exec=0.2-r3 \ tini=0.19.0-r3 && \ deluser git && \ @@ -37,17 +46,18 @@ RUN apk add --no-cache \ git config --system init.defaultBranch main && \ install -d -m 0750 -o git -g git /var/lib/gilti/git /var/cache/cgit && \ install -d -m 0700 -o root -g root /var/lib/gilti/ssh && \ - install -d -m 0755 /run/gilti /run/gilti-bootstrap && \ - rm -rf /var/cache/apk/* /etc/nginx/http.d/default.conf + install -d -m 0750 -o git -g git /run/gilti && \ + install -d -m 0755 /run/gilti-bootstrap && \ + rm -rf /var/cache/apk/* +COPY --from=builder --chown=root:root /src/target/release/gilti-httpd /usr/local/bin/gilti-httpd COPY --chown=root:root config/cgitrc /etc/cgitrc -COPY --chown=root:root config/nginx.conf /etc/nginx/nginx.conf COPY --chown=root:root config/sshd_config /etc/ssh/sshd_config COPY --chown=root:root scripts/entrypoint.sh /usr/local/bin/gilti-entrypoint COPY --chown=root:root LICENSE README.md /usr/share/doc/gilti/ -RUN chmod 0755 /usr/local/bin/gilti-entrypoint && \ - nginx -t -e /dev/stderr -c /etc/nginx/nginx.conf +RUN chmod 0755 /usr/local/bin/gilti-entrypoint /usr/local/bin/gilti-httpd && \ + /usr/local/bin/gilti-httpd --check EXPOSE 8080 2222 VOLUME ["/var/lib/gilti"] diff --git a/README.md b/README.md index 6c3ff10..018e5ac 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Gilti — a tiny Git server in a box Gilti packages [cgit](https://git.zx2c4.com/cgit/), -[Gitolite](https://gitolite.com/gitolite/), nginx, OpenSSH, and fcgiwrap into -one OCI service with a Helm chart. It is intended for small authoritative Git -installations where SSH is the only Git transport and selected repositories are -published through a read-only web interface. +[Gitolite](https://gitolite.com/gitolite/), OpenSSH, and a small Tower-based +HTTP-to-CGI gateway into one OCI service with a Helm chart. It is intended for +small authoritative Git installations where SSH is the only Git transport and +selected repositories are published through a read-only web interface. The first Gilti installation is `vcs.dimidiumlabs.io`, the authoritative Git service for Dimidium Labs. @@ -73,6 +73,9 @@ Provision tools and run static checks: ```console mise bootstrap +cargo fmt -- --check +cargo test --locked +cargo clippy --locked --all-targets -- -D warnings shellcheck scripts/*.sh tests/*.sh mise run chart -- --chart charts/gilti --lint-only ``` diff --git a/REUSE.toml b/REUSE.toml index 17c1c78..199d61f 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -7,6 +7,7 @@ version = 1 path = [ "charts/gilti/templates/*", "charts/gilti/values.schema.json", + "Cargo.lock", "mise.lock", ] SPDX-FileCopyrightText = "2026 Nikolay Govorov" diff --git a/charts/gilti/README.md b/charts/gilti/README.md index 46eea9f..30ab1d9 100644 --- a/charts/gilti/README.md +++ b/charts/gilti/README.md @@ -74,8 +74,9 @@ as a unit. ## Security context -The nginx and cgit workers run without privileges. OpenSSH intentionally keeps a -root master so it can enter the `git` account (UID/GID 10000). The chart drops +The Rust HTTP gateway and its cgit children run without privileges. OpenSSH +intentionally keeps a root master so it can enter the `git` account (UID/GID +10000). The chart drops all capabilities and restores only `CHOWN`, `DAC_OVERRIDE`, `FOWNER`, `SETGID`, `SETUID`, and `SYS_CHROOT`. The root filesystem is read-only; state, cache, `/run`, and `/tmp` are explicit writable mounts. diff --git a/config/nginx.conf b/config/nginx.conf deleted file mode 100644 index 5022863..0000000 --- a/config/nginx.conf +++ /dev/null @@ -1,57 +0,0 @@ -# SPDX-FileCopyrightText: 2026 Nikolay Govorov -# SPDX-License-Identifier: AGPL-3.0-or-later - -user git git; -worker_processes auto; -error_log /dev/stderr info; -pid /run/gilti/nginx.pid; - -events { - worker_connections 256; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - access_log /dev/stdout; - sendfile on; - server_tokens off; - client_max_body_size 1m; - client_body_timeout 10s; - keepalive_timeout 30s; - - server { - listen 8080; - server_name _; - - location = /healthz { - access_log off; - default_type text/plain; - return 200 "ok\n"; - } - - location = /cgit.css { - alias /usr/share/webapps/cgit/cgit.css; - } - - location = /cgit.png { - alias /usr/share/webapps/cgit/cgit.png; - } - - location = /favicon.ico { - alias /usr/share/webapps/cgit/favicon.ico; - } - - location / { - limit_except GET HEAD { deny all; } - - include /etc/nginx/fastcgi_params; - fastcgi_param SCRIPT_FILENAME /usr/share/webapps/cgit/cgit.cgi; - fastcgi_param PATH_INFO $uri; - fastcgi_param QUERY_STRING $args; - fastcgi_param HTTP_HOST $host; - fastcgi_pass unix:/run/gilti/fcgiwrap.sock; - fastcgi_read_timeout 30s; - } - } -} diff --git a/crates/gilti/Cargo.toml b/crates/gilti/Cargo.toml new file mode 100644 index 0000000..0010d28 --- /dev/null +++ b/crates/gilti/Cargo.toml @@ -0,0 +1,24 @@ +# SPDX-FileCopyrightText: 2026 Nikolay Govorov +# SPDX-License-Identifier: AGPL-3.0-or-later + +[package] +name = "gilti" +description = "Gilti web-server and daemon" +version.workspace = true + +authors.workspace = true +edition.workspace = true +license.workspace = true +publish.workspace = true +homepage.workspace = true +repository.workspace = true + +[[bin]] +name = "gilti-httpd" +path = "src/main.rs" + +[dependencies] +axum.workspace = true +percent-encoding.workspace = true +tokio.workspace = true +tower.workspace = true diff --git a/crates/gilti/src/cgi.rs b/crates/gilti/src/cgi.rs new file mode 100644 index 0000000..57800df --- /dev/null +++ b/crates/gilti/src/cgi.rs @@ -0,0 +1,230 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! HTTP-to-CGI Tower service. + +#[derive(Clone, Copy)] +pub struct RemoteAddr(pub std::net::SocketAddr); + +#[derive(Clone)] +pub struct Cgi { + program: std::path::PathBuf, + current_dir: std::path::PathBuf, + environment: Vec<(std::ffi::OsString, std::ffi::OsString)>, + server_addr: std::net::SocketAddr, +} + +impl Cgi { + pub fn new( + program: impl Into, + current_dir: impl Into, + server_addr: std::net::SocketAddr, + ) -> Self { + Self { + program: program.into(), + current_dir: current_dir.into(), + environment: Vec::new(), + server_addr, + } + } + + pub fn env( + mut self, + name: impl Into, + value: impl Into, + ) -> Self { + self.environment.push((name.into(), value.into())); + self + } + + async fn execute( + self, + request: axum::http::Request, + ) -> std::io::Result> { + let remote = request.extensions().get::().map_or_else( + || std::net::SocketAddr::from(([0, 0, 0, 0], 0)), + |value| value.0, + ); + let (parts, body) = request.into_parts(); + let body = axum::body::to_bytes(body, 1024 * 1024) + .await + .map_err(std::io::Error::other)?; + let path = percent_encoding::percent_decode_str(parts.uri.path()).collect::>(); + if path.contains(&0) { + return Err(invalid("request path contains a null byte")); + } + let path = ::from_vec(path); + let host = parts + .headers + .get(axum::http::header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or("localhost"); + let content_type = parts + .headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + let request_uri = parts + .uri + .path_and_query() + .map_or(parts.uri.path(), |value| value.as_str()); + + let mut command = tokio::process::Command::new(&self.program); + command + .env_clear() + .envs(self.environment) + .env("GATEWAY_INTERFACE", "CGI/1.1") + .env("SERVER_PROTOCOL", "HTTP/1.1") + .env("REQUEST_METHOD", parts.method.as_str()) + .env("SCRIPT_FILENAME", &self.program) + .env("SCRIPT_NAME", "") + .env("PATH_INFO", path) + .env("QUERY_STRING", parts.uri.query().unwrap_or("")) + .env("REQUEST_URI", request_uri) + .env("REMOTE_ADDR", remote.ip().to_string()) + .env("REMOTE_PORT", remote.port().to_string()) + .env("SERVER_ADDR", self.server_addr.ip().to_string()) + .env("SERVER_PORT", self.server_addr.port().to_string()) + .env("SERVER_NAME", host) + .env("CONTENT_LENGTH", body.len().to_string()) + .env("CONTENT_TYPE", content_type) + .env("HTTP_HOST", host) + .current_dir(self.current_dir) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::inherit()); + + for (name, value) in &parts.headers { + if name == axum::http::header::HOST + || name == axum::http::header::CONTENT_LENGTH + || name == axum::http::header::CONTENT_TYPE + || name == "proxy" + { + continue; + } + if let Ok(value) = value.to_str() { + command.env( + format!( + "HTTP_{}", + name.as_str().to_ascii_uppercase().replace('-', "_") + ), + value, + ); + } + } + + let mut child = command.spawn()?; + let mut stdin = child + .stdin + .take() + .ok_or_else(|| std::io::Error::other("CGI stdin is unavailable"))?; + let write = async move { + tokio::io::AsyncWriteExt::write_all(&mut stdin, &body).await?; + tokio::io::AsyncWriteExt::shutdown(&mut stdin).await + }; + let (write, output) = tokio::join!(write, child.wait_with_output()); + write?; + let output = output?; + if !output.status.success() { + return Err(std::io::Error::other(format!( + "CGI program exited with {}", + output.status + ))); + } + + let (status, headers, body) = parse_response(&output.stdout)?; + let mut response = axum::http::Response::builder() + .status(status) + .body(axum::body::Body::from(body.to_vec())) + .map_err(std::io::Error::other)?; + *response.headers_mut() = headers; + Ok(response) + } +} + +impl tower::Service> for Cgi { + type Response = axum::http::Response; + type Error = std::io::Error; + type Future = std::pin::Pin< + Box> + Send>, + >; + + fn poll_ready( + &mut self, + _context: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + fn call(&mut self, request: axum::http::Request) -> Self::Future { + let service = self.clone(); + Box::pin(async move { service.execute(request).await }) + } +} + +fn parse_response( + output: &[u8], +) -> std::io::Result<(axum::http::StatusCode, axum::http::HeaderMap, &[u8])> { + let (head, body) = if let Some(offset) = output.windows(4).position(|part| part == b"\r\n\r\n") + { + (&output[..offset], &output[offset + 4..]) + } else if let Some(offset) = output.windows(2).position(|part| part == b"\n\n") { + (&output[..offset], &output[offset + 2..]) + } else { + return Err(invalid("CGI response has no header separator")); + }; + + let mut status = axum::http::StatusCode::OK; + let mut headers = axum::http::HeaderMap::new(); + for line in head.split(|byte| *byte == b'\n') { + let line = line.strip_suffix(b"\r").unwrap_or(line); + let colon = line + .iter() + .position(|byte| *byte == b':') + .ok_or_else(|| invalid("malformed CGI header"))?; + let (name, mut value) = (&line[..colon], &line[colon + 1..]); + while value.first().is_some_and(u8::is_ascii_whitespace) { + value = &value[1..]; + } + if name.eq_ignore_ascii_case(b"Status") { + status = axum::http::StatusCode::from_bytes( + value.split(|byte| *byte == b' ').next().unwrap_or(value), + ) + .map_err(|_| invalid("invalid CGI status"))?; + } else { + headers.append( + axum::http::HeaderName::from_bytes(name) + .map_err(|_| invalid("invalid CGI header"))?, + axum::http::HeaderValue::from_bytes(value) + .map_err(|_| invalid("invalid CGI header"))?, + ); + } + } + if status == axum::http::StatusCode::OK && headers.contains_key("location") { + status = axum::http::StatusCode::FOUND; + } + Ok((status, headers, body)) +} + +fn invalid(message: &'static str) -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::InvalidData, message) +} + +#[cfg(test)] +mod tests { + #[test] + fn parses_cgi_response() { + let (status, headers, body) = + super::parse_response(b"Status: 201 Created\r\nContent-Type: text/plain\r\n\r\nhello") + .unwrap(); + assert_eq!(status, axum::http::StatusCode::CREATED); + assert_eq!(headers[axum::http::header::CONTENT_TYPE], "text/plain"); + assert_eq!(body, b"hello"); + } + + #[test] + fn is_a_tower_service() { + fn assert_service>>() {} + assert_service::(); + } +} diff --git a/crates/gilti/src/main.rs b/crates/gilti/src/main.rs new file mode 100644 index 0000000..aa36590 --- /dev/null +++ b/crates/gilti/src/main.rs @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: 2026 Nikolay Govorov +// SPDX-License-Identifier: AGPL-3.0-or-later + +mod cgi; + +const DEFAULT_LISTEN_ADDR: &str = "0.0.0.0:8080"; + +const CGIT: &str = "/usr/share/webapps/cgit/cgit.cgi"; +const CGIT_CONFIG: &str = "/etc/cgitrc"; +const GIT_HOME: &str = "/var/lib/gilti/git"; +const RUN_DIR: &str = "/run/gilti"; + +const CGIT_CSS: &str = "/usr/share/webapps/cgit/cgit.css"; +const CGIT_LOGO: &str = "/usr/share/webapps/cgit/cgit.png"; +const CGIT_FAVICON: &str = "/usr/share/webapps/cgit/favicon.ico"; + +#[derive(Clone)] +struct AppState { + cgit: cgi::Cgi, +} + +struct CgitConfig { + path: std::path::PathBuf, +} + +impl CgitConfig { + fn create() -> std::io::Result { + let contents = std::fs::read(CGIT_CONFIG)?; + let path = std::path::PathBuf::from(format!("{RUN_DIR}/cgitrc.{}", std::process::id())); + let mut options = std::fs::OpenOptions::new(); + options.write(true).create_new(true); + std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600); + let mut file = options.open(&path)?; + let config = Self { path }; + std::io::Write::write_all(&mut file, &contents)?; + Ok(config) + } +} + +impl Drop for CgitConfig { + fn drop(&mut self) { + match std::fs::remove_file(&self.path) { + Err(error) if error.kind() != std::io::ErrorKind::NotFound => { + eprintln!( + "gilti-httpd: cannot remove {}: {error}", + self.path.display() + ); + } + _ => {} + } + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let listen_addr = std::env::var("GILTI_HTTP_ADDR") + .unwrap_or_else(|_| DEFAULT_LISTEN_ADDR.to_owned()) + .parse::()?; + check_files()?; + + if std::env::args().nth(1).as_deref() == Some("--check") { + return Ok(()); + } + + let cgit_config = CgitConfig::create()?; + let state = AppState { + cgit: cgi::Cgi::new(CGIT, GIT_HOME, listen_addr) + .env("CGIT_CONFIG", cgit_config.path.as_os_str()) + .env("HOME", GIT_HOME) + .env("PATH", "/usr/bin:/bin"), + }; + let app = axum::Router::new() + .route( + "/healthz", + axum::routing::get(async || plain_response(axum::http::StatusCode::OK, "ok\n")), + ) + .route( + "/cgit.css", + axum::routing::get(async || static_file(CGIT_CSS, "text/css")), + ) + .route( + "/cgit.png", + axum::routing::get(async || static_file(CGIT_LOGO, "image/png")), + ) + .route( + "/favicon.ico", + axum::routing::get(async || static_file(CGIT_FAVICON, "image/x-icon")), + ) + .fallback(proxy_to_cgit) + .with_state(state); + + let listener = tokio::net::TcpListener::bind(listen_addr).await?; + eprintln!("gilti-httpd: listening on {listen_addr}"); + + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown_signal()) + .await?; + + Ok(()) +} + +fn check_files() -> std::io::Result<()> { + let metadata = std::fs::metadata(CGIT)?; + if !metadata.is_file() + || std::os::unix::fs::PermissionsExt::mode(&metadata.permissions()) & 0o111 == 0 + { + return Err(std::io::Error::other(format!("{CGIT} is not executable"))); + } + for path in [CGIT_CONFIG, CGIT_CSS, CGIT_LOGO, CGIT_FAVICON] { + if !std::fs::metadata(path)?.is_file() { + return Err(std::io::Error::other(format!( + "{path} is not a regular file" + ))); + } + } + Ok(()) +} + +async fn shutdown_signal() { + let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("install SIGTERM handler"); + let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()) + .expect("install SIGINT handler"); + tokio::select! { + _ = terminate.recv() => {} + _ = interrupt.recv() => {} + } +} + +fn static_file(path: &str, content_type: &'static str) -> axum::response::Response { + match std::fs::read(path) { + Ok(bytes) => response(axum::http::StatusCode::OK, content_type, bytes), + Err(error) => { + eprintln!("gilti-httpd: cannot read {path}: {error}"); + plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n") + } + } +} + +async fn proxy_to_cgit( + axum::extract::State(state): axum::extract::State, + axum::extract::ConnectInfo(remote): axum::extract::ConnectInfo, + mut request: axum::extract::Request, +) -> axum::response::Response { + if request.method() != axum::http::Method::GET && request.method() != axum::http::Method::HEAD { + return plain_response(axum::http::StatusCode::FORBIDDEN, "forbidden\n"); + } + request.extensions_mut().insert(cgi::RemoteAddr(remote)); + + match tower::ServiceExt::oneshot(state.cgit.clone(), request).await { + Ok(response) => response, + Err(error) => { + eprintln!("gilti-httpd: cgit request failed: {error}"); + plain_response(axum::http::StatusCode::BAD_GATEWAY, "bad gateway\n") + } + } +} + +fn response( + status: axum::http::StatusCode, + content_type: &'static str, + body: Vec, +) -> axum::response::Response { + axum::response::Response::builder() + .status(status) + .header(axum::http::header::CONTENT_TYPE, content_type) + .body(axum::body::Body::from(body)) + .expect("static response is valid") +} + +fn plain_response( + status: axum::http::StatusCode, + message: &'static str, +) -> axum::response::Response { + response(status, "text/plain", message.as_bytes().to_vec()) +} diff --git a/mise.lock b/mise.lock index 08e84e7..07771c0 100644 --- a/mise.lock +++ b/mise.lock @@ -32,6 +32,10 @@ url = "https://get.helm.sh/helm-v4.1.1-darwin-amd64.tar.gz" checksum = "sha256:bd0528a18d8a22431426eab3138a9479dfae00350719ac4150e81b123bfd6081" url = "https://get.helm.sh/helm-v4.1.1-windows-amd64.tar.gz" +[[tools.rust]] +version = "1.87.0" +backend = "core:rust" + [[tools.shellcheck]] version = "0.11.0" backend = "aqua:koalaman/shellcheck" diff --git a/mise.toml b/mise.toml index 58fe70a..ecb9878 100644 --- a/mise.toml +++ b/mise.toml @@ -8,6 +8,7 @@ experimental = true [tools] helm = "4.1.1" +rust = "1.87.0" shellcheck = "0.11.0" [task_config] diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 4859c63..26fd734 100755 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -63,9 +63,9 @@ prepare_runtime() { install -d -m 0755 -o root -g root "$state" install -d -m 0750 -o git -g git "$git_home" "$cache_dir" install -d -m 0700 -o root -g root "$host_key_dir" - install -d -m 0755 "$run_dir" + install -d -m 0750 -o git -g git "$run_dir" chown git:git "$cache_dir" - rm -f "$run_dir/fcgiwrap.sock" "$run_dir/nginx.pid" "$run_dir/sshd.pid" + rm -f "$run_dir/sshd.pid" "$run_dir"/cgitrc.* } state_status() { @@ -133,26 +133,26 @@ initialize() { chmod 0644 "$host_key.pub.tmp" mv -f "$host_key.pub.tmp" "$host_key.pub" - nginx -t -e /dev/stderr -c /etc/nginx/nginx.conf + /usr/local/bin/gilti-httpd --check /usr/sbin/sshd -t -f /etc/ssh/sshd_config } stop_services() { trap - TERM INT HUP - for pid in ${fcgi_pid:-} ${sshd_pid:-} ${nginx_pid:-}; do + for pid in ${httpd_pid:-} ${sshd_pid:-}; do kill -TERM "$pid" 2>/dev/null || true done attempts=0 while [ "$attempts" -lt 50 ]; do running=false - for pid in ${fcgi_pid:-} ${sshd_pid:-} ${nginx_pid:-}; do + for pid in ${httpd_pid:-} ${sshd_pid:-}; do kill -0 "$pid" 2>/dev/null && running=true done [ "$running" = true ] || break attempts=$((attempts + 1)) sleep 0.1 done - for pid in ${fcgi_pid:-} ${sshd_pid:-} ${nginx_pid:-}; do + for pid in ${httpd_pid:-} ${sshd_pid:-}; do kill -KILL "$pid" 2>/dev/null || true wait "$pid" 2>/dev/null || true done @@ -162,21 +162,17 @@ supervise() { initialize trap stop_services TERM INT HUP - HOME="$git_home" spawn-fcgi -n \ - -s "$run_dir/fcgiwrap.sock" -M 0660 -U git -G git \ - -u git -g git -- /usr/bin/fcgiwrap -f & - fcgi_pid=$! - /usr/sbin/sshd -D -e -f /etc/ssh/sshd_config & sshd_pid=$! - nginx -e /dev/stderr -c /etc/nginx/nginx.conf -g 'daemon off;' & - nginx_pid=$! + su-exec git:git env HOME="$git_home" USER=git LOGNAME=git \ + /usr/local/bin/gilti-httpd & + httpd_pid=$! while :; do - for pid in "$fcgi_pid" "$sshd_pid" "$nginx_pid"; do + for pid in "$sshd_pid" "$httpd_pid"; do if ! kill -0 "$pid" 2>/dev/null; then - if wait "$pid"; then status=0; else status=$?; fi + if wait "$pid"; then status=1; else status=$?; fi log "a service exited; stopping the pod" stop_services exit "$status" diff --git a/tests/smoke.sh b/tests/smoke.sh index a64a97b..1c50b35 100755 --- a/tests/smoke.sh +++ b/tests/smoke.sh @@ -81,6 +81,37 @@ start() { } start with-key +[ "$(curl -fsS "http://127.0.0.1:$http_port/healthz")" = ok ] || { + echo 'unexpected health response' >&2 + exit 1 +} +status=$(curl -sS -o /dev/null -w '%{http_code}' -X POST "http://127.0.0.1:$http_port/") +[ "$status" = 403 ] || { + echo "POST to cgit returned HTTP $status instead of 403" >&2 + exit 1 +} +curl -fsS "http://127.0.0.1:$http_port/cgit.css" | grep -q 'cgit' +content_type=$(curl -fsSI "http://127.0.0.1:$http_port/cgit.css" | + awk -F ': ' 'tolower($1) == "content-type" { gsub("\\r", "", $2); print $2 }') +[ "$content_type" = text/css ] || { + echo "unexpected cgit.css content type: $content_type" >&2 + exit 1 +} +curl -fsSI "http://127.0.0.1:$http_port/" >/dev/null +# shellcheck disable=SC2016 # Expanded by the shell inside the container. +httpd_uid=$("$engine" exec "$name" sh -c ' + for comm in /proc/[0-9]*/comm; do + [ "$(cat "$comm")" = gilti-httpd ] || continue + stat -c %u "${comm%/comm}" + exit + done + exit 1 +') +[ "$httpd_uid" = 10000 ] || { + echo "gilti-httpd runs as unexpected UID $httpd_uid" >&2 + exit 1 +} + host_key_mode=$("$engine" exec "$name" stat -c '%u:%a' /var/lib/gilti/ssh/ssh_host_ed25519_key) [ "$host_key_mode" = 0:600 ] || { echo "unexpected SSH host-key ownership/mode: $host_key_mode" >&2 -- Gilti