aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorNikolay Govorov <me@govorov.online>2026-08-25 23:07:48 +0100
committerNikolay Govorov <me@govorov.online>2026-08-25 23:07:48 +0100
commit3d7085358b8461241d42bacf12b909d2c82f32e2 (patch)
treef7b7a4ea230c07b67852bb418115c6f30ad0337b
parentf567b620cd4e7418be586fae1cf42c8e8351ab0b (diff)
downloadtar
tar.gz
tar.bz2
tar.lz
tar.xz
tar.zst
zip
Replace nginx to tiny rust server
Diffstat
-rw-r--r--.dockerignore1+1 −0
-rw-r--r--.github/workflows/build.yml3+3 −0
-rw-r--r--.gitignore1+1 −0
-rw-r--r--Cargo.lock415+415 −0
-rw-r--r--Cargo.toml27+27 −0
-rw-r--r--Dockerfile26+18 −8
-rw-r--r--README.md11+7 −4
-rw-r--r--REUSE.toml1+1 −0
-rw-r--r--charts/gilti/README.md5+3 −2
-rw-r--r--config/nginx.conf57+0 −57
-rw-r--r--crates/gilti/Cargo.toml24+24 −0
-rw-r--r--crates/gilti/src/cgi.rs230+230 −0
-rw-r--r--crates/gilti/src/main.rs179+179 −0
-rw-r--r--mise.lock4+4 −0
-rw-r--r--mise.toml1+1 −0
-rwxr-xr-xscripts/entrypoint.sh26+11 −15
-rwxr-xr-xtests/smoke.sh31+31 −0
17 files changed, 956 insertions, 86 deletions
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
--- /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
--- /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 <me@govorov.online>"]
+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
--- 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
--- /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
--- /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<std::path::PathBuf>,
+ current_dir: impl Into<std::path::PathBuf>,
+ 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<std::ffi::OsString>,
+ value: impl Into<std::ffi::OsString>,
+ ) -> Self {
+ self.environment.push((name.into(), value.into()));
+ self
+ }
+
+ async fn execute(
+ self,
+ request: axum::http::Request<axum::body::Body>,
+ ) -> std::io::Result<axum::http::Response<axum::body::Body>> {
+ let remote = request.extensions().get::<RemoteAddr>().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::<Vec<_>>();
+ if path.contains(&0) {
+ return Err(invalid("request path contains a null byte"));
+ }
+ let path = <std::ffi::OsString as std::os::unix::ffi::OsStringExt>::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<axum::http::Request<axum::body::Body>> for Cgi {
+ type Response = axum::http::Response<axum::body::Body>;
+ type Error = std::io::Error;
+ type Future = std::pin::Pin<
+ Box<dyn std::future::Future<Output = std::io::Result<Self::Response>> + Send>,
+ >;
+
+ fn poll_ready(
+ &mut self,
+ _context: &mut std::task::Context<'_>,
+ ) -> std::task::Poll<std::io::Result<()>> {
+ std::task::Poll::Ready(Ok(()))
+ }
+
+ fn call(&mut self, request: axum::http::Request<axum::body::Body>) -> 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<T: tower::Service<axum::http::Request<axum::body::Body>>>() {}
+ assert_service::<super::Cgi>();
+ }
+}
diff --git a/crates/gilti/src/main.rs b/crates/gilti/src/main.rs
new file mode 100644
--- /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<Self> {
+ 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<dyn std::error::Error>> {
+ let listen_addr = std::env::var("GILTI_HTTP_ADDR")
+ .unwrap_or_else(|_| DEFAULT_LISTEN_ADDR.to_owned())
+ .parse::<std::net::SocketAddr>()?;
+ 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::<std::net::SocketAddr>(),
+ )
+ .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<AppState>,
+ axum::extract::ConnectInfo(remote): axum::extract::ConnectInfo<std::net::SocketAddr>,
+ 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<u8>,
+) -> 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