aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorNikolay Govorov <me@govorov.online>2026-08-25 23:41:44 +0100
committerNikolay Govorov <me@govorov.online>2026-08-26 00:20:40 +0100
commit4bf8b797b38f61e17274a015bf7c729617fea209 (patch)
tree73f67fdf29832f8bb7f4208a15d88ae14a97368d
parent3d7085358b8461241d42bacf12b909d2c82f32e2 (diff)
downloadtar
tar.gz
tar.bz2
tar.lz
tar.xz
tar.zst
zip
Replace gitolite to tiny rust cli
Diffstat
-rw-r--r--.github/workflows/build.yml45+39 −6
-rw-r--r--Cargo.lock7+7 −0
-rw-r--r--Cargo.toml1+1 −0
-rw-r--r--Dockerfile28+16 −12
-rw-r--r--README.md50+23 −27
-rw-r--r--charts/gilti/Chart.yaml2+1 −1
-rw-r--r--charts/gilti/README.md66+33 −33
-rw-r--r--charts/gilti/templates/configmap.yaml10+9 −1
-rw-r--r--charts/gilti/templates/deployment.yaml16+5 −11
-rw-r--r--charts/gilti/values.schema.json7+5 −2
-rw-r--r--charts/gilti/values.yaml6+3 −3
-rw-r--r--config/cgitrc1+0 −1
-rw-r--r--config/sshd_config7+5 −2
-rw-r--r--crates/gilti-ssh/Cargo.toml17+17 −0
-rw-r--r--crates/gilti-ssh/src/main.rs288+288 −0
-rw-r--r--crates/gilti/Cargo.toml4+0 −4
-rw-r--r--crates/gilti/src/main.rs13+5 −8
-rw-r--r--deny.toml32+32 −0
-rw-r--r--mise.lock6+5 −1
-rw-r--r--mise.toml6+5 −1
-rwxr-xr-xscripts/entrypoint.sh153+63 −90
-rwxr-xr-xtests/chart.sh6+3 −3
-rwxr-xr-xtests/smoke.sh162+114 −48
23 files changed, 679 insertions, 254 deletions
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 163f258..599b6df 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -23,22 +23,55 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+
- uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3
with:
version: 2026.7.5
experimental: true
- install: true
- cache: true
- - run: cargo fmt -- --check
- - run: cargo test --locked
- - run: cargo clippy --locked --all-targets -- -D warnings
+ install: false
+ - name: Install dependencies
+ run: mise bootstrap --locked --yes --update
+ - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
+
+ - name: Check formatting
+ run: cargo fmt --all --check
+
- run: shellcheck scripts/*.sh tests/*.sh
+
+ build:
+ name: Build (${{ matrix.arch }})
+ runs-on: ${{ matrix.runner }}
+ needs: [check]
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - runner: ubuntu-24.04
+ arch: amd64
+ - runner: ubuntu-24.04-arm
+ arch: arm64
+ steps:
+ - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
+
+ - uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3
+ with:
+ version: 2026.7.5
+ experimental: true
+ install: false
+ - name: Install dependencies
+ run: mise bootstrap --locked --yes --update
+ - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2.8.2
+
+ - run: cargo clippy --locked --all-targets --all-features --release -- -D warnings
+ - run: cargo build --release
+
+ - run: cargo test --all-features --release --locked
- run: tests/chart.sh
image:
name: Image and smoke test
runs-on: ubuntu-latest
- needs: [check]
+ needs: [build]
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: jdx/mise-action@5228313ee0372e111a38da051671ca30fc5a96db # v3
diff --git a/Cargo.lock b/Cargo.lock
index 687f5d8..e34a357 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -114,6 +114,13 @@ dependencies = [
]
[[package]]
+name = "gilti-ssh"
+version = "0.1.0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index eb6b195..5af0365 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,6 +16,7 @@ repository = "https://github.com/dimidiumlabs/gilti"
[workspace.dependencies]
axum = { version = "0.8.4", default-features = false, features = ["http1", "tokio"] }
+libc = "0.2.172"
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"] }
diff --git a/Dockerfile b/Dockerfile
index 0443205..2b7ad10 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -14,7 +14,7 @@ RUN apk add --no-cache \
WORKDIR /src
COPY Cargo.toml Cargo.lock ./
COPY crates ./crates
-RUN cargo build --locked --release --package gilti --bin gilti-httpd
+RUN cargo build --locked --release --workspace
FROM docker.io/library/alpine:${ALPINE_VERSION}@${ALPINE_DIGEST}
@@ -22,7 +22,7 @@ ARG VERSION=dev
ARG REVISION=unknown
LABEL org.opencontainers.image.title="Gilti" \
- org.opencontainers.image.description="Boxed tiny Git server powered by cgit and Gitolite" \
+ org.opencontainers.image.description="Boxed tiny Git server powered by cgit and OpenSSH" \
org.opencontainers.image.source="https://github.com/dimidiumlabs/gilti" \
org.opencontainers.image.version="$VERSION" \
org.opencontainers.image.revision="$REVISION" \
@@ -31,33 +31,37 @@ LABEL org.opencontainers.image.title="Gilti" \
RUN apk add --no-cache \
cgit=1.2.3-r5 \
git=2.49.1-r0 \
- gitolite=3.6.13-r1 \
libgcc=14.2.0-r6 \
openssh-keygen=10.0_p1-r10 \
openssh-server=10.0_p1-r10 \
- perl=5.40.4-r0 \
su-exec=0.2-r3 \
tini=0.19.0-r3 && \
- deluser git && \
addgroup -S -g 10000 git && \
install -d -m 0755 -o root -g root /var/lib/gilti && \
adduser -S -D -u 10000 -G git -h /var/lib/gilti/git -s /bin/sh git && \
passwd -d git && \
- git config --system init.defaultBranch main && \
- install -d -m 0750 -o git -g git /var/lib/gilti/git /var/cache/cgit && \
+ install -d -m 0750 -o git -g git \
+ /var/lib/gilti/git /var/lib/gilti/git/repositories /var/cache/cgit && \
install -d -m 0700 -o root -g root /var/lib/gilti/ssh && \
- install -d -m 0750 -o git -g git /run/gilti && \
- install -d -m 0755 /run/gilti-bootstrap && \
+ install -d -m 0755 -o root -g root /run/gilti && \
+ install -d -m 0750 -o git -g git /run/gilti/http && \
+ install -d -m 0750 -o root -g git /run/gilti/ssh && \
+ install -d -m 0755 /etc/gilti && \
rm -rf /var/cache/apk/*
-COPY --from=builder --chown=root:root /src/target/release/gilti-httpd /usr/local/bin/gilti-httpd
+COPY --from=builder --chown=root:root /src/target/release/gilti /usr/local/bin/gilti
+COPY --from=builder --chown=root:root /src/target/release/gilti-ssh /usr/local/bin/gilti-ssh
COPY --chown=root:root config/cgitrc /etc/cgitrc
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 /usr/local/bin/gilti-httpd && \
- /usr/local/bin/gilti-httpd --check
+RUN chmod 0755 \
+ /usr/local/bin/gilti-entrypoint \
+ /usr/local/bin/gilti \
+ /usr/local/bin/gilti-ssh && \
+ /usr/local/bin/gilti --check && \
+ /usr/local/bin/gilti-ssh --check
EXPOSE 8080 2222
VOLUME ["/var/lib/gilti"]
diff --git a/README.md b/README.md
index 018e5ac..e0cced8 100644
--- a/README.md
+++ b/README.md
@@ -1,23 +1,18 @@
# Gilti — a tiny Git server in a box
-Gilti packages [cgit](https://git.zx2c4.com/cgit/),
-[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.
+Gilti is a tiny web UI for Git that can function as either a read-only showcase
+or a Git SSH server by integrating with the system's `sshd`. It is designed for
+open-source projects and small teams that want to break free from major hosting
+platforms but aren't ready to host complex services like Forgejo.
-The first Gilti installation is `vcs.dimidiumlabs.io`, the authoritative Git
-service for Dimidium Labs.
-
-> Gilti is in its infancy. Back up the persistent volume and test restoration
-> before storing irreplaceable repositories.
+> Gilti is a young project. Mirror your repositories and create backups.
## Security boundary
-- Git fetch and push use SSH public-key authentication through Gitolite.
-- cgit is anonymous and read-only. It does not inherit Gitolite ACLs.
-- Only repositories exported to Gitolite's `gitweb` pseudo-user are listed by
- cgit; unrestricted repository scanning is deliberately disabled.
+- Git fetch and push use SSH public-key authentication through `gilti-ssh`.
+- Every configured key has read/write access to every repository and may create
+ a repository by pushing to its name for the first time.
+- cgit is anonymous and read-only; every repository is publicly visible.
- Smart HTTP, password authentication, shells, forwarding, tunnels, and cgit
filters are disabled.
- Gilti is a single-replica service backed by one POSIX persistent volume. It is
@@ -25,11 +20,12 @@ service for Dimidium Labs.
## Container
-A fresh state directory requires an administrator public key:
+Every start requires a static `authorized_keys` file:
```console
docker build -t gilti:dev .
ssh-keygen -q -t ed25519 -N '' -f ./admin
+cp ./admin.pub ./authorized_keys
docker run --rm \
--read-only --cap-drop ALL \
@@ -40,27 +36,27 @@ docker run --rm \
--tmpfs /var/cache/cgit:rw,nosuid,nodev,noexec,size=1g \
-p 8080:8080 -p 2222:2222 \
-v gilti-state:/var/lib/gilti \
- -v "$PWD/admin.pub:/run/gilti-bootstrap/admin.pub:ro" \
+ -v "$PWD/authorized_keys:/etc/gilti/authorized_keys:ro" \
gilti:dev
```
-The bootstrap key is used only when the volume is fresh. Subsequent starts do
-not require it. Partial Gitolite state fails closed instead of being
-reinitialized. SSH host keys live on the same volume and remain stable across
-pod replacement.
+Gilti snapshots this file at process startup; changing it takes effect after a
+restart. Repositories and the persistent SSH host key live on the state volume.
## Helm
-Create the bootstrap Secret before the first install:
+Configure the allowed public keys in values:
-```console
-kubectl create namespace gilti
-kubectl -n gilti create secret generic gilti-bootstrap \
- --from-file=admin.pub="$HOME/.ssh/id_ed25519.pub"
+```yaml
+ssh:
+ authorizedKeys:
+ - ssh-ed25519 AAAA... operator@example
+```
+```console
helm upgrade --install gilti ./charts/gilti \
- --namespace gilti \
- --set bootstrap.existingSecret=gilti-bootstrap \
+ --namespace gilti --create-namespace \
+ --values values.yaml \
--set cgit.clonePrefix='ssh://git@vcs.dimidiumlabs.io/'
```
diff --git a/charts/gilti/Chart.yaml b/charts/gilti/Chart.yaml
index 2c050c8..34cd2c0 100644
--- a/charts/gilti/Chart.yaml
+++ b/charts/gilti/Chart.yaml
@@ -8,4 +8,4 @@ appVersion: 0.1.0
name: gilti
home: https://github.com/dimidiumlabs/gilti
-description: Boxed tiny Git server powered by cgit and Gitolite
+description: Boxed tiny Git server powered by cgit and OpenSSH
diff --git a/charts/gilti/README.md b/charts/gilti/README.md
index 30ab1d9..35a3903 100644
--- a/charts/gilti/README.md
+++ b/charts/gilti/README.md
@@ -1,41 +1,39 @@
# Gilti Helm chart
The chart installs one Gilti replica backed by one persistent volume. Upgrades
-use `Recreate`: two pods must never write the Gitolite filesystem concurrently.
-The generated PVC carries `helm.sh/resource-policy: keep` by default, so
-uninstalling the release does not delete authoritative Git data.
+use `Recreate`: two pods must never write the repository filesystem
+concurrently. The generated PVC carries `helm.sh/resource-policy: keep` by
+default, so uninstalling the release does not delete authoritative Git data.
-## Bootstrap
+## SSH access
-A fresh volume requires an existing Secret containing the administrator's SSH
-public key as `admin.pub`:
+Public keys are static chart configuration:
-```console
-kubectl create secret generic gilti-bootstrap --from-file=admin.pub
-helm upgrade --install gilti . \
- --set bootstrap.existingSecret=gilti-bootstrap
+```yaml
+ssh:
+ authorizedKeys:
+ - ssh-ed25519 AAAA... operator@example
+ - ssh-ed25519 AAAA... automation@example
```
-Additional `*.pub` entries in the same Secret are committed to `gitolite-admin`
-as additional keys for the same `admin` identity. Bootstrap keys are ignored
-after successful initialization and the Secret may then be removed from values.
-A partially initialized volume is never overwritten automatically.
-
-## Publishing repositories
+Every configured key has the same permissions: it may fetch, push, and create
+any repository. There are no users, per-repository ACLs, or live key updates.
+Gilti snapshots the configured keys when the pod starts, so a key change takes
+effect after the Deployment rolls out the updated ConfigMap.
-cgit does not implement Gitolite authorization. Gilti therefore reads Gitolite's
-generated `projects.list`; a repository becomes public only when the `gitweb`
-pseudo-user can read it:
+Shells, forwarding, tunnels, and arbitrary SSH commands are disabled. A push to
+a missing repository initializes it as a bare repository:
-```text
-repo example
- RW+ = alice
- R = gitweb
+```console
+git remote add origin ssh://git@git.example.test/example
+git push -u origin main
```
-After pushing this configuration to `gitolite-admin`, `example` appears in cgit.
-Repositories not present in `projects.list` are unavailable even through a
-direct cgit URL. Web cloning and snapshots are disabled in the default policy.
+## Repository visibility
+
+cgit is anonymous and read-only and scans the complete repository directory.
+Consequently every repository available over SSH is also publicly visible over
+HTTP. Web cloning and snapshots are disabled in the default policy.
## Networking
@@ -50,6 +48,9 @@ listener.
Example for the first Dimidium Labs installation:
```yaml
+ssh:
+ authorizedKeys:
+ - ssh-ed25519 AAAA... operator@example
cgit:
clonePrefix: ssh://git@vcs.dimidiumlabs.io/
httpRoute:
@@ -68,15 +69,14 @@ sshRoute:
```
TLS terminates at the shared Gateway; the chart does not create certificates.
-SSH host keys, Gitolite configuration, repositories, generated authorization,
-and audit logs reside on the persistent volume. Back up and restore that volume
-as a unit.
+Repositories and the SSH host key reside on the persistent volume. Public keys
+are stored in the generated ConfigMap and must be supplied on every deployment.
## Security context
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.
+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/charts/gilti/templates/configmap.yaml b/charts/gilti/templates/configmap.yaml
index a35361a..9f73a90 100644
--- a/charts/gilti/templates/configmap.yaml
+++ b/charts/gilti/templates/configmap.yaml
@@ -6,6 +6,11 @@
{{- fail (printf "%s must be a single line" $name) }}
{{- end }}
{{- end }}
+{{- range $key := .Values.ssh.authorizedKeys }}
+{{- if or (eq $key "") (regexMatch "[\\r\\n]" $key) }}
+{{- fail "ssh.authorizedKeys entries must be non-empty single lines" }}
+{{- end }}
+{{- end }}
apiVersion: v1
kind: ConfigMap
metadata:
@@ -35,10 +40,13 @@ data:
enable-tree-linenumbers=1
snapshots=
remove-suffix=1
- project-list=/var/lib/gilti/git/projects.list
readme=:README.md
readme=:README
{{- with .Values.cgit.clonePrefix }}
clone-prefix={{ . }}
{{- end }}
scan-path=/var/lib/gilti/git/repositories
+ authorized_keys: |
+ {{- range .Values.ssh.authorizedKeys }}
+ {{ . }}
+ {{- end }}
diff --git a/charts/gilti/templates/deployment.yaml b/charts/gilti/templates/deployment.yaml
index 1193fca..ad3c40c 100644
--- a/charts/gilti/templates/deployment.yaml
+++ b/charts/gilti/templates/deployment.yaml
@@ -56,13 +56,13 @@ spec:
protocol: TCP
startupProbe:
httpGet:
- path: /
+ path: /healthz
port: http
failureThreshold: 60
periodSeconds: 5
readinessProbe:
httpGet:
- path: /
+ path: /healthz
port: http
periodSeconds: 10
livenessProbe:
@@ -79,8 +79,9 @@ spec:
mountPath: /etc/cgitrc
subPath: cgitrc
readOnly: true
- - name: bootstrap
- mountPath: /run/gilti-bootstrap
+ - name: config
+ mountPath: /etc/gilti/authorized_keys
+ subPath: authorized_keys
readOnly: true
- name: run
mountPath: /run
@@ -99,13 +100,6 @@ spec:
- name: config
configMap:
name: {{ include "gilti.fullname" . }}
- - name: bootstrap
- {{- if .Values.bootstrap.existingSecret }}
- secret:
- secretName: {{ .Values.bootstrap.existingSecret }}
- {{- else }}
- emptyDir: {}
- {{- end }}
- name: run
emptyDir:
sizeLimit: {{ .Values.runtime.runSizeLimit }}
diff --git a/charts/gilti/values.schema.json b/charts/gilti/values.schema.json
index b7ace1b..100a06c 100644
--- a/charts/gilti/values.schema.json
+++ b/charts/gilti/values.schema.json
@@ -13,10 +13,13 @@
"pullPolicy": { "enum": ["Always", "IfNotPresent", "Never"] }
}
},
- "bootstrap": {
+ "ssh": {
"type": "object",
"properties": {
- "existingSecret": { "type": "string" }
+ "authorizedKeys": {
+ "type": "array",
+ "items": { "type": "string", "minLength": 1, "pattern": "^[^\\r\\n]+$" }
+ }
}
},
"cgit": {
diff --git a/charts/gilti/values.yaml b/charts/gilti/values.yaml
index 04518b5..553e3e9 100644
--- a/charts/gilti/values.yaml
+++ b/charts/gilti/values.yaml
@@ -13,9 +13,9 @@ imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
-bootstrap:
- # Secret key is needed only while initializing a fresh persistent volume.
- existingSecret: ""
+ssh:
+ # Every listed key has read/write access to every repository.
+ authorizedKeys: []
cgit:
rootTitle: Gilti
diff --git a/config/cgitrc b/config/cgitrc
index f74be44..b3926f6 100644
--- a/config/cgitrc
+++ b/config/cgitrc
@@ -26,7 +26,6 @@ enable-tree-linenumbers=1
snapshots=
remove-suffix=1
-project-list=/var/lib/gilti/git/projects.list
readme=:README.md
readme=:README
scan-path=/var/lib/gilti/git/repositories
diff --git a/config/sshd_config b/config/sshd_config
index 0760fe7..80f749b 100644
--- a/config/sshd_config
+++ b/config/sshd_config
@@ -5,7 +5,7 @@ Port 2222
ListenAddress 0.0.0.0
AddressFamily any
HostKey /var/lib/gilti/ssh/ssh_host_ed25519_key
-PidFile /run/gilti/sshd.pid
+PidFile /run/gilti/ssh/sshd.pid
AllowUsers git
AuthenticationMethods publickey
@@ -15,7 +15,9 @@ KbdInteractiveAuthentication no
PermitEmptyPasswords no
PermitRootLogin no
StrictModes yes
-AuthorizedKeysFile .ssh/authorized_keys
+AuthorizedKeysFile /run/gilti/ssh/authorized_keys
+ForceCommand /usr/local/bin/gilti-ssh
+AcceptEnv GIT_PROTOCOL
DisableForwarding yes
AllowAgentForwarding no
@@ -24,6 +26,7 @@ GatewayPorts no
PermitTunnel no
PermitTTY no
PermitUserEnvironment no
+PermitUserRC no
X11Forwarding no
LogLevel INFO
diff --git a/crates/gilti-ssh/Cargo.toml b/crates/gilti-ssh/Cargo.toml
new file mode 100644
--- /dev/null
+++ b/crates/gilti-ssh/Cargo.toml
@@ -0,0 +1,17 @@
+# SPDX-FileCopyrightText: 2026 Nikolay Govorov
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+[package]
+name = "gilti-ssh"
+description = "Restricted SSH command for Gilti Git access"
+version.workspace = true
+
+repository.workspace = true
+homepage.workspace = true
+authors.workspace = true
+edition.workspace = true
+license.workspace = true
+publish.workspace = true
+
+[dependencies]
+libc.workspace = true
diff --git a/crates/gilti-ssh/src/main.rs b/crates/gilti-ssh/src/main.rs
new file mode 100644
--- /dev/null
+++ b/crates/gilti-ssh/src/main.rs
@@ -0,0 +1,288 @@
+// SPDX-FileCopyrightText: 2026 Nikolay Govorov
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+//! Restricted OpenSSH forced command for Git services.
+//!
+//! Every authenticated key is fully trusted. Repository directories, their
+//! configuration, and server-side hooks are trusted administrator state.
+
+const GIT_HOME: &str = "/var/lib/gilti/git";
+const REPOSITORIES: &str = "/var/lib/gilti/git/repositories";
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum GitService {
+ ReceivePack,
+ UploadArchive,
+ UploadPack,
+}
+
+impl GitService {
+ fn parse(value: &str) -> Option<Self> {
+ match value {
+ "git-receive-pack" => Some(Self::ReceivePack),
+ "git-upload-archive" => Some(Self::UploadArchive),
+ "git-upload-pack" => Some(Self::UploadPack),
+ _ => None,
+ }
+ }
+
+ fn program(self) -> &'static str {
+ match self {
+ Self::ReceivePack => "/usr/bin/git-receive-pack",
+ Self::UploadArchive => "/usr/bin/git-upload-archive",
+ Self::UploadPack => "/usr/bin/git-upload-pack",
+ }
+ }
+}
+
+fn main() -> std::process::ExitCode {
+ // SAFETY: setting the process umask has no memory-safety implications.
+ unsafe {
+ libc::umask(0o077);
+ }
+
+ match run() {
+ Ok(()) => std::process::ExitCode::SUCCESS,
+ Err(error) => {
+ eprintln!("gilti-ssh: {error}");
+ std::process::ExitCode::FAILURE
+ }
+ }
+}
+
+fn run() -> Result<(), String> {
+ if std::env::args_os().nth(1).as_deref() == Some(std::ffi::OsStr::new("--check")) {
+ return check_installation();
+ }
+
+ let remote = std::env::var("SSH_CONNECTION")
+ .ok()
+ .and_then(|connection| connection.split_whitespace().next().map(str::to_owned))
+ .ok_or_else(|| "SSH_CONNECTION is missing".to_owned())?;
+ let command = match std::env::var("SSH_ORIGINAL_COMMAND") {
+ Ok(command) if !command.is_empty() => command,
+ _ => {
+ eprintln!("gilti-ssh: authenticated connection from {remote}");
+ println!("Gilti: authenticated. Shell access is disabled.");
+ return Ok(());
+ }
+ };
+ if command.contains(['\n', '\r']) {
+ return Err("newlines are not allowed in SSH_ORIGINAL_COMMAND".to_owned());
+ }
+
+ let (service, repository) = parse_command(&command)?;
+ eprintln!("gilti-ssh: {service:?} {repository} from {remote}");
+ let root = std::path::Path::new(REPOSITORIES);
+ let root_metadata = std::fs::symlink_metadata(root)
+ .map_err(|error| format!("cannot inspect {REPOSITORIES}: {error}"))?;
+ if !root_metadata.file_type().is_dir() {
+ return Err(format!("{REPOSITORIES} is not a real directory"));
+ }
+ let path = root.join(format!("{repository}.git"));
+
+ if service == GitService::ReceivePack && !path.exists() {
+ create_repository(root, &path)?;
+ }
+ let metadata = std::fs::symlink_metadata(&path)
+ .map_err(|_| format!("repository '{repository}' does not exist"))?;
+ if !metadata.file_type().is_dir() {
+ return Err(format!("repository '{repository}' is not a real directory"));
+ }
+ verify_repository_path(root, &path)?;
+
+ let mut command = git_command(service.program());
+ if let Some(protocol) = std::env::var_os("GIT_PROTOCOL") {
+ command.env("GIT_PROTOCOL", protocol);
+ }
+ let error = std::os::unix::process::CommandExt::exec(command.arg(path));
+ Err(format!("cannot execute {}: {error}", service.program()))
+}
+
+fn check_installation() -> Result<(), String> {
+ let metadata = std::fs::symlink_metadata(REPOSITORIES)
+ .map_err(|error| format!("cannot inspect {REPOSITORIES}: {error}"))?;
+ if !metadata.file_type().is_dir() {
+ return Err(format!("{REPOSITORIES} is not a real directory"));
+ }
+ for service in [
+ GitService::ReceivePack,
+ GitService::UploadArchive,
+ GitService::UploadPack,
+ ] {
+ let metadata = std::fs::metadata(service.program())
+ .map_err(|error| format!("cannot inspect {}: {error}", service.program()))?;
+ if !metadata.is_file()
+ || std::os::unix::fs::PermissionsExt::mode(&metadata.permissions()) & 0o111 == 0
+ {
+ return Err(format!("{} is not executable", service.program()));
+ }
+ }
+ Ok(())
+}
+
+fn parse_command(command: &str) -> Result<(GitService, String), String> {
+ let (program, argument) = command
+ .split_once(' ')
+ .ok_or_else(|| "only Git protocol commands are allowed".to_owned())?;
+ let service = GitService::parse(program)
+ .ok_or_else(|| "only Git protocol commands are allowed".to_owned())?;
+ let repository = parse_repository(argument)?;
+ Ok((service, repository))
+}
+
+fn parse_repository(argument: &str) -> Result<String, String> {
+ let argument = argument
+ .strip_prefix('\'')
+ .and_then(|value| value.strip_suffix('\''));
+ let argument = argument.ok_or_else(|| "repository must be single-quoted".to_owned())?;
+ let argument = argument.strip_prefix('/').unwrap_or(argument);
+ let argument = argument.strip_suffix(".git").unwrap_or(argument);
+
+ if argument.is_empty()
+ || argument.len() > 1024
+ || !argument.as_bytes()[0].is_ascii_alphanumeric()
+ || argument.contains("..")
+ || argument.contains(".git/")
+ {
+ return Err("invalid repository name".to_owned());
+ }
+ for component in argument.split('/') {
+ if component.is_empty()
+ || component == "."
+ || component == ".."
+ || !component
+ .bytes()
+ .all(|byte| byte.is_ascii_alphanumeric() || b"-_.".contains(&byte))
+ {
+ return Err("invalid repository name".to_owned());
+ }
+ }
+ Ok(argument.to_owned())
+}
+
+fn verify_repository_path(root: &std::path::Path, path: &std::path::Path) -> Result<(), String> {
+ let root = std::fs::canonicalize(root)
+ .map_err(|error| format!("cannot resolve {}: {error}", root.display()))?;
+ let path = std::fs::canonicalize(path)
+ .map_err(|error| format!("cannot resolve {}: {error}", path.display()))?;
+ if !path.starts_with(root) {
+ return Err("repository escapes the repository directory".to_owned());
+ }
+ Ok(())
+}
+
+fn verify_creation_parent(root: &std::path::Path, path: &std::path::Path) -> Result<(), String> {
+ let mut ancestor = path
+ .parent()
+ .ok_or_else(|| "repository has no parent directory".to_owned())?;
+ loop {
+ match std::fs::symlink_metadata(ancestor) {
+ Ok(metadata) => {
+ if !metadata.is_dir() {
+ return Err(format!("{} is not a directory", ancestor.display()));
+ }
+ return verify_repository_path(root, ancestor);
+ }
+ Err(error) if error.kind() == std::io::ErrorKind::NotFound && ancestor != root => {
+ ancestor = ancestor
+ .parent()
+ .ok_or_else(|| "repository escapes the repository directory".to_owned())?;
+ }
+ Err(error) => {
+ return Err(format!("cannot inspect {}: {error}", ancestor.display()));
+ }
+ }
+ }
+}
+
+fn git_command(program: &str) -> std::process::Command {
+ let mut command = std::process::Command::new(program);
+ command
+ .env_clear()
+ .env("HOME", GIT_HOME)
+ .env("USER", "git")
+ .env("LOGNAME", "git")
+ .env("PATH", "/usr/bin:/bin")
+ .env("GIT_CONFIG_GLOBAL", "/dev/null")
+ .env("GIT_CONFIG_NOSYSTEM", "1")
+ .env("GIT_TERMINAL_PROMPT", "0");
+ command
+}
+
+fn create_repository(root: &std::path::Path, path: &std::path::Path) -> Result<(), String> {
+ verify_creation_parent(root, path)?;
+ let parent = path
+ .parent()
+ .ok_or_else(|| "repository has no parent directory".to_owned())?;
+ std::fs::create_dir_all(parent)
+ .map_err(|error| format!("cannot create {}: {error}", parent.display()))?;
+ verify_repository_path(root, parent)?;
+
+ let status = git_command("/usr/bin/git")
+ .args(["init", "--quiet", "--bare", "--initial-branch=main", "--"])
+ .arg(path)
+ .status()
+ .map_err(|error| format!("cannot initialize {}: {error}", path.display()))?;
+ if !status.success() {
+ return Err(format!(
+ "cannot initialize {}: git exited with {status}",
+ path.display()
+ ));
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn parses_git_commands() {
+ assert_eq!(
+ super::parse_command("git-upload-pack 'group/project.git'").unwrap(),
+ (super::GitService::UploadPack, "group/project".to_owned())
+ );
+ assert_eq!(
+ super::parse_command("git-receive-pack '/project'").unwrap(),
+ (super::GitService::ReceivePack, "project".to_owned())
+ );
+ }
+
+ #[test]
+ fn rejects_other_commands_and_unsafe_names() {
+ for command in [
+ "sh -c true",
+ "git-upload-pack '../../etc/passwd'",
+ "git-upload-pack 'repo..backup'",
+ "git-upload-pack 'outer.git/inner'",
+ "git-upload-pack '.hidden'",
+ "git-upload-pack 'repo name'",
+ "git-upload-pack 'repo' trailing",
+ "git-upload-pack repo",
+ "git-upload-pack 'repo'\nwhoami",
+ ] {
+ assert!(super::parse_command(command).is_err(), "accepted {command}");
+ }
+ }
+
+ #[test]
+ fn refuses_to_create_through_a_symlinked_parent() {
+ static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
+
+ let base = std::env::temp_dir().join(format!(
+ "gilti-ssh-test-{}-{}",
+ std::process::id(),
+ NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
+ ));
+ let root = base.join("repositories");
+ let outside = base.join("outside");
+ std::fs::create_dir_all(&root).unwrap();
+ std::fs::create_dir_all(&outside).unwrap();
+ std::os::unix::fs::symlink(&outside, root.join("group")).unwrap();
+
+ let repository = root.join("group/project.git");
+ assert!(super::create_repository(&root, &repository).is_err());
+ assert!(!outside.join("project.git").exists());
+
+ std::fs::remove_dir_all(base).unwrap();
+ }
+}
diff --git a/crates/gilti/Cargo.toml b/crates/gilti/Cargo.toml
index 0010d28..7722322 100644
--- a/crates/gilti/Cargo.toml
+++ b/crates/gilti/Cargo.toml
@@ -13,10 +13,6 @@ 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
diff --git a/crates/gilti/src/main.rs b/crates/gilti/src/main.rs
index aa36590..2645d94 100644
--- a/crates/gilti/src/main.rs
+++ b/crates/gilti/src/main.rs
@@ -8,7 +8,7 @@ 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 RUN_DIR: &str = "/run/gilti/http";
const CGIT_CSS: &str = "/usr/share/webapps/cgit/cgit.css";
const CGIT_LOGO: &str = "/usr/share/webapps/cgit/cgit.png";
@@ -41,10 +41,7 @@ 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()
- );
+ eprintln!("gilti: cannot remove {}: {error}", self.path.display());
}
_ => {}
}
@@ -90,7 +87,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.with_state(state);
let listener = tokio::net::TcpListener::bind(listen_addr).await?;
- eprintln!("gilti-httpd: listening on {listen_addr}");
+ eprintln!("gilti: listening on {listen_addr}");
axum::serve(
listener,
@@ -134,7 +131,7 @@ fn static_file(path: &str, content_type: &'static str) -> axum::response::Respon
match std::fs::read(path) {
Ok(bytes) => response(axum::http::StatusCode::OK, content_type, bytes),
Err(error) => {
- eprintln!("gilti-httpd: cannot read {path}: {error}");
+ eprintln!("gilti: cannot read {path}: {error}");
plain_response(axum::http::StatusCode::NOT_FOUND, "not found\n")
}
}
@@ -153,7 +150,7 @@ async fn proxy_to_cgit(
match tower::ServiceExt::oneshot(state.cgit.clone(), request).await {
Ok(response) => response,
Err(error) => {
- eprintln!("gilti-httpd: cgit request failed: {error}");
+ eprintln!("gilti: cgit request failed: {error}");
plain_response(axum::http::StatusCode::BAD_GATEWAY, "bad gateway\n")
}
}
diff --git a/deny.toml b/deny.toml
new file mode 100644
--- /dev/null
+++ b/deny.toml
@@ -0,0 +1,32 @@
+# SPDX-FileCopyrightText: 2026 Nikolay Govorov
+# SPDX-License-Identifier: AGPL-3.0-or-later
+
+[licenses]
+allow = [
+ # Dilution licenses available without restrictions
+ "MIT",
+ "ISC",
+ "Apache-2.0",
+ "BSD-3-Clause",
+
+ # Custom but acceptable free licenses
+ "Zlib",
+ "Unicode-3.0",
+
+ # GPL versions
+ "GPL-2.0-or-later",
+ "GPL-3.0-or-later",
+ "LGPL-2.0-or-later",
+ "LGPL-2.1-or-later",
+ "LGPL-3.0-or-later",
+]
+exceptions = [
+ # The project itself is distributed under the AGPL, but avoid it in dependencies
+ { crate = "gilti", allow = ["AGPL-3.0-or-later"] },
+ { crate = "gilti-ssh", allow = ["AGPL-3.0-or-later"] },
+]
+unused-allowed-license = "allow"
+
+[sources]
+unknown-git = "deny"
+unknown-registry = "deny"
diff --git a/mise.lock b/mise.lock
index 07771c0..8cccd80 100644
--- a/mise.lock
+++ b/mise.lock
@@ -33,9 +33,13 @@ checksum = "sha256:bd0528a18d8a22431426eab3138a9479dfae00350719ac4150e81b123bfd6
url = "https://get.helm.sh/helm-v4.1.1-windows-amd64.tar.gz"
[[tools.rust]]
-version = "1.87.0"
+version = "1.97.1"
backend = "core:rust"
+[tools.rust.options]
+components = "clippy,llvm-tools-preview,rustfmt"
+profile = "minimal"
+
[[tools.shellcheck]]
version = "0.11.0"
backend = "aqua:koalaman/shellcheck"
diff --git a/mise.toml b/mise.toml
index ecb9878..6101ba9 100644
--- a/mise.toml
+++ b/mise.toml
@@ -8,7 +8,11 @@ experimental = true
[tools]
helm = "4.1.1"
-rust = "1.87.0"
+rust = { version = "1.97.1", profile = "minimal", components = [
+ "clippy",
+ "llvm-tools-preview",
+ "rustfmt",
+] }
shellcheck = "0.11.0"
[task_config]
diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh
index 26fd734..1fa909b 100755
--- a/scripts/entrypoint.sh
+++ b/scripts/entrypoint.sh
@@ -4,113 +4,83 @@
# shellcheck shell=dash
set -eu
+umask 077
state=/var/lib/gilti
run_dir=/run/gilti
+http_run_dir=$run_dir/http
+ssh_run_dir=$run_dir/ssh
cache_dir=/var/cache/cgit
git_home=$state/git
+repositories=$git_home/repositories
host_key_dir=$state/ssh
-
-bootstrap_dir=/run/gilti-bootstrap
-admin_key=${GILTI_ADMIN_KEY_FILE:-$bootstrap_dir/admin.pub}
+authorized_keys_source=${GILTI_AUTHORIZED_KEYS_FILE:-/etc/gilti/authorized_keys}
+authorized_keys=$ssh_run_dir/authorized_keys
log() {
printf 'gilti: %s\n' "$*" >&2
}
-run_as_git() {
- su-exec git:git env HOME="$git_home" USER=git LOGNAME=git "$@"
-}
-
-validate_public_key_file() {
- key_file=$1
- [ "$(awk 'NF && $1 !~ /^#/ { count++ } END { print count + 0 }' "$key_file")" -eq 1 ] &&
- ssh-keygen -l -f "$key_file" >/dev/null 2>&1
+prepare_runtime() {
+ [ "$(id -u)" -eq 0 ] || { log "the supervisor must start as root"; exit 1; }
+ for path in "$git_home" "$repositories"; do
+ [ ! -L "$path" ] || { log "refusing symlinked state path $path"; exit 1; }
+ done
+ install -d -m 0755 -o root -g root "$state"
+ install -d -m 0750 -o git -g git "$git_home" "$repositories" "$cache_dir"
+ install -d -m 0700 -o root -g root "$host_key_dir"
+ install -d -m 0755 -o root -g root "$run_dir"
+ install -d -m 0750 -o git -g git "$http_run_dir"
+ install -d -m 0750 -o root -g git "$ssh_run_dir"
+ rm -f "$ssh_run_dir/sshd.pid" "$http_run_dir"/cgitrc.* \
+ "$authorized_keys" "$authorized_keys".*
}
-validate_bootstrap_keys() {
- [ -r "$admin_key" ] || {
- log "fresh state requires an admin public key at $admin_key"
+prepare_authorized_keys() {
+ [ -f "$authorized_keys_source" ] && [ -r "$authorized_keys_source" ] || {
+ log "SSH public keys are required at $authorized_keys_source"
exit 1
}
- for key_file in "$bootstrap_dir"/*.pub; do
- [ -e "$key_file" ] || continue
- validate_public_key_file "$key_file" || {
- log "bootstrap key $key_file is not exactly one valid SSH public key"
- exit 1
- }
- done
-}
-stage_additional_admin_keys() {
- install -d -m 0750 -o git -g git "$git_home/.gitolite"
- install -d -m 0750 -o git -g git "$git_home/.gitolite/keydir"
- install -d -m 0750 -o git -g git "$git_home/.gitolite/logs"
+ output=$authorized_keys.tmp.$$
+ candidate=$authorized_keys.key.$$
+ : >"$output"
count=0
- for key_file in "$bootstrap_dir"/*.pub; do
- [ -e "$key_file" ] || continue
- [ "$key_file" = "$admin_key" ] && continue
+ while IFS= read -r key || [ -n "$key" ]; do
+ case $key in
+ ''|'#'*) continue ;;
+ esac
+ case $key in
+ ssh-*|ecdsa-*|sk-*) ;;
+ *)
+ rm -f "$output" "$candidate"
+ log "$authorized_keys_source contains an invalid SSH public key"
+ exit 1
+ ;;
+ esac
+ printf '%s\n' "$key" >"$candidate"
+ if ! ssh-keygen -l -f "$candidate" >/dev/null 2>&1; then
+ rm -f "$output" "$candidate"
+ log "$authorized_keys_source contains an invalid SSH public key"
+ exit 1
+ fi
+ printf 'restrict %s\n' "$key" >>"$output"
count=$((count + 1))
- destination=$git_home/.gitolite/keydir/gilti-bootstrap-$count
- install -d -m 0750 -o git -g git "$destination"
- install -m 0644 -o git -g git "$key_file" "$destination/admin.pub"
- done
-}
+ done <"$authorized_keys_source"
+ rm -f "$candidate"
-prepare_runtime() {
- [ "$(id -u)" -eq 0 ] || { log "the supervisor must start as root"; exit 1; }
- 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 0750 -o git -g git "$run_dir"
- chown git:git "$cache_dir"
- rm -f "$run_dir/sshd.pid" "$run_dir"/cgitrc.*
-}
-
-state_status() {
- complete=true
- for path in .gitolite.rc .gitolite repositories .ssh/authorized_keys; do
- [ -e "$git_home/$path" ] || complete=false
- done
- if [ "$complete" = true ]; then
- printf '%s\n' complete
- return
- fi
-
- partial=false
- for path in .gitolite.rc .gitolite repositories .ssh/authorized_keys projects.list; do
- [ ! -e "$git_home/$path" ] || partial=true
- done
- if [ "$partial" = true ]; then
- printf '%s\n' partial
- else
- printf '%s\n' fresh
+ if [ "$count" -eq 0 ]; then
+ rm -f "$output"
+ log "$authorized_keys_source contains no SSH public keys"
+ exit 1
fi
+ chown root:git "$output"
+ chmod 0640 "$output"
+ mv -f "$output" "$authorized_keys"
}
-initialize() {
- prepare_runtime
-
- case $(state_status) in
- complete)
- ;;
- partial)
- log "refusing to overwrite partial Gitolite state in $git_home"
- exit 1
- ;;
- fresh)
- validate_bootstrap_keys
- stage_additional_admin_keys
- log "initializing Gitolite"
- run_as_git gitolite setup -pk "$admin_key"
- ;;
- esac
-
- if [ ! -e "$git_home/projects.list" ]; then
- install -m 0640 -o git -g git /dev/null "$git_home/projects.list"
- fi
-
+prepare_host_key() {
host_key=$host_key_dir/ssh_host_ed25519_key
if [ -L "$host_key" ]; then
log "refusing symlinked SSH host key"
@@ -132,8 +102,14 @@ initialize() {
ssh-keygen -y -f "$host_key" >"$host_key.pub.tmp"
chmod 0644 "$host_key.pub.tmp"
mv -f "$host_key.pub.tmp" "$host_key.pub"
+}
- /usr/local/bin/gilti-httpd --check
+prepare() {
+ prepare_runtime
+ prepare_authorized_keys
+ prepare_host_key
+ /usr/local/bin/gilti --check
+ /usr/local/bin/gilti-ssh --check
/usr/sbin/sshd -t -f /etc/ssh/sshd_config
}
@@ -159,14 +135,14 @@ stop_services() {
}
supervise() {
- initialize
+ prepare
trap stop_services TERM INT HUP
/usr/sbin/sshd -D -e -f /etc/ssh/sshd_config &
sshd_pid=$!
su-exec git:git env HOME="$git_home" USER=git LOGNAME=git \
- /usr/local/bin/gilti-httpd &
+ /usr/local/bin/gilti &
httpd_pid=$!
while :; do
@@ -183,9 +159,6 @@ supervise() {
}
case ${1:-serve} in
- init)
- initialize
- ;;
serve)
supervise
;;
diff --git a/tests/chart.sh b/tests/chart.sh
index d649656..356bce3 100755
--- a/tests/chart.sh
+++ b/tests/chart.sh
@@ -15,7 +15,7 @@ injected=$(mktemp)
trap 'rm -f "$rendered" "$invalid" "$injected"' EXIT
helm template gilti "$chart" \
- --set bootstrap.existingSecret=gilti-bootstrap \
+ --set-string 'ssh.authorizedKeys[0]=ssh-ed25519 AAAAcharttest gilti' \
--set httpRoute.enabled=true \
--set 'httpRoute.hostnames[0]=git.example.test' \
--set 'httpRoute.parentRefs[0].name=public' \
@@ -26,8 +26,8 @@ grep -q '^kind: HTTPRoute$' "$rendered"
grep -q '^kind: TCPRoute$' "$rendered"
grep -q '^apiVersion: gateway.networking.k8s.io/v1$' "$rendered"
grep -q 'helm.sh/resource-policy: keep' "$rendered"
-grep -q 'secretName: gilti-bootstrap' "$rendered"
-grep -q 'mountPath: /run/gilti-bootstrap' "$rendered"
+grep -q 'ssh-ed25519 AAAAcharttest gilti' "$rendered"
+grep -q 'mountPath: /etc/gilti/authorized_keys' "$rendered"
cat >"$injected" <<'EOF'
cgit:
diff --git a/tests/smoke.sh b/tests/smoke.sh
index 1c50b35..1c397ad 100755
--- a/tests/smoke.sh
+++ b/tests/smoke.sh
@@ -10,6 +10,7 @@ volume=$name-state
http_port=${HTTP_PORT:-18080}
ssh_port=${SSH_PORT:-12222}
work=$(mktemp -d)
+authorized_keys=$work/authorized_keys
remove_container() {
"$engine" stop -t 10 "$name" >/dev/null 2>&1 || true
@@ -26,30 +27,22 @@ trap cleanup EXIT INT TERM
ssh-keygen -q -t ed25519 -N '' -f "$work/admin"
ssh-keygen -q -t ed25519 -N '' -f "$work/admin-2"
ssh-keygen -q -t ed25519 -N '' -f "$work/stranger"
-mkdir "$work/bootstrap"
-cp "$work/admin.pub" "$work/bootstrap/admin.pub"
-cp "$work/admin-2.pub" "$work/bootstrap/admin-2.pub"
+cat "$work/admin.pub" "$work/admin-2.pub" >"$authorized_keys"
"$engine" volume create "$volume" >/dev/null
-printf '%s\n' 'not an SSH key' >"$work/bootstrap/bad.pub"
+printf '%s\n' 'not an SSH key' >"$work/bad-authorized-keys"
if "$engine" run --rm \
--cap-drop ALL \
--cap-add CHOWN --cap-add DAC_OVERRIDE --cap-add FOWNER \
--cap-add SETGID --cap-add SETUID --cap-add SYS_CHROOT \
--mount "type=volume,src=$volume,dst=/var/lib/gilti" \
- --mount "type=bind,src=$work/bootstrap,dst=/run/gilti-bootstrap,readonly" \
- "$image" init >/dev/null 2>&1; then
- echo 'initialization accepted a malformed additional key' >&2
+ --mount "type=bind,src=$work/bad-authorized-keys,dst=/etc/gilti/authorized_keys,readonly" \
+ "$image" >/dev/null 2>&1; then
+ echo 'initialization accepted a malformed SSH public key' >&2
exit 1
fi
-rm "$work/bootstrap/bad.pub"
start() {
- key_mount=
- if [ "${1:-with-key}" = with-key ]; then
- key_mount="--mount type=bind,src=$work/bootstrap,dst=/run/gilti-bootstrap,readonly"
- fi
- # shellcheck disable=SC2086
"$engine" run -d --name "$name" \
--read-only \
--cap-drop ALL \
@@ -59,7 +52,7 @@ start() {
--tmpfs /tmp:rw,nosuid,nodev,noexec,size=256m \
--tmpfs /var/cache/cgit:rw,nosuid,nodev,noexec,size=256m \
--mount "type=volume,src=$volume,dst=/var/lib/gilti" \
- $key_mount \
+ --mount "type=bind,src=$authorized_keys,dst=/etc/gilti/authorized_keys,readonly" \
-p "127.0.0.1:$http_port:8080" -p "127.0.0.1:$ssh_port:2222" \
"$image" >/dev/null
@@ -80,7 +73,26 @@ start() {
done
}
-start with-key
+start
+sshd_config=$("$engine" exec "$name" /usr/sbin/sshd -T -f /etc/ssh/sshd_config \
+ -C user=git,host=localhost,addr=127.0.0.1)
+for expected in \
+ 'authenticationmethods publickey' \
+ 'passwordauthentication no' \
+ 'kbdinteractiveauthentication no' \
+ 'permitemptypasswords no' \
+ 'permitrootlogin no' \
+ 'disableforwarding yes' \
+ 'permittty no' \
+ 'permituserrc no' \
+ 'permituserenvironment no' \
+ 'forcecommand /usr/local/bin/gilti-ssh' \
+ 'authorizedkeysfile /run/gilti/ssh/authorized_keys'; do
+ printf '%s\n' "$sshd_config" | grep -Fqx "$expected" || {
+ echo "effective sshd configuration is missing: $expected" >&2
+ exit 1
+ }
+done
[ "$(curl -fsS "http://127.0.0.1:$http_port/healthz")" = ok ] || {
echo 'unexpected health response' >&2
exit 1
@@ -97,18 +109,19 @@ content_type=$(curl -fsSI "http://127.0.0.1:$http_port/cgit.css" |
echo "unexpected cgit.css content type: $content_type" >&2
exit 1
}
-curl -fsSI "http://127.0.0.1:$http_port/" >/dev/null
+curl -fsSI "http://127.0.0.1:$http_port/healthz" >/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
+ [ "$(cat "$comm")" = gilti ] || continue
stat -c %u "${comm%/comm}"
exit
done
exit 1
')
[ "$httpd_uid" = 10000 ] || {
- echo "gilti-httpd runs as unexpected UID $httpd_uid" >&2
+ echo "gilti runs as unexpected UID $httpd_uid" >&2
exit 1
}
@@ -117,39 +130,90 @@ host_key_mode=$("$engine" exec "$name" stat -c '%u:%a' /var/lib/gilti/ssh/ssh_ho
echo "unexpected SSH host-key ownership/mode: $host_key_mode" >&2
exit 1
}
+keys_mode=$("$engine" exec "$name" stat -c '%u:%g:%a' /run/gilti/ssh/authorized_keys)
+[ "$keys_mode" = 0:10000:640 ] || {
+ echo "unexpected authorized_keys ownership/mode: $keys_mode" >&2
+ exit 1
+}
+if "$engine" exec --user 10000:10000 "$name" sh -c \
+ 'printf x >>/run/gilti/ssh/authorized_keys' 2>/dev/null; then
+ echo 'Git/cgit user can modify authorized_keys' >&2
+ exit 1
+fi
+if "$engine" exec --user 10000:10000 "$name" rm /run/gilti/ssh/authorized_keys \
+ 2>/dev/null; then
+ echo 'Git/cgit user can remove authorized_keys' >&2
+ exit 1
+fi
if "$engine" exec --user 10000:10000 "$name" test -r /var/lib/gilti/ssh/ssh_host_ed25519_key; then
echo 'Git/cgit user can read the SSH host private key' >&2
exit 1
fi
+
ssh_opts="-o BatchMode=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=$work/known_hosts -i $work/admin -p $ssh_port"
+ssh_opts_2="-o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i $work/admin-2 -p $ssh_port"
# shellcheck disable=SC2086
-ssh $ssh_opts git@127.0.0.1 info | grep -q 'hello admin'
-ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
- -i "$work/admin-2" -p "$ssh_port" git@127.0.0.1 info | grep -q 'hello admin'
+ssh $ssh_opts git@127.0.0.1 | grep -q 'Gilti: authenticated'
+# shellcheck disable=SC2086
+ssh $ssh_opts_2 git@127.0.0.1 | grep -q 'Gilti: authenticated'
if ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
- -i "$work/stranger" -p "$ssh_port" git@127.0.0.1 info >/dev/null 2>&1; then
+ -i "$work/stranger" -p "$ssh_port" git@127.0.0.1 >/dev/null 2>&1; then
echo 'an unknown key was accepted' >&2
exit 1
fi
+# shellcheck disable=SC2086
+if ssh $ssh_opts git@127.0.0.1 "git-upload-pack '../../etc/passwd'" >/dev/null 2>&1; then
+ echo 'gilti-ssh accepted an unsafe repository path' >&2
+ exit 1
+fi
-GIT_SSH_COMMAND="ssh $ssh_opts" git clone \
- "ssh://git@127.0.0.1:$ssh_port/gitolite-admin" "$work/admin-repo"
+# A writable HOME must not let repository traffic install process-wide hooks.
+# shellcheck disable=SC2016 # Expanded by the shell inside the container.
+"$engine" exec --user 10000:10000 "$name" env HOME=/var/lib/gilti/git sh -c '
+ mkdir -p "$HOME/evil-hooks"
+ printf "#!/bin/sh\ntouch /tmp/global-hook-ran\n" >"$HOME/evil-hooks/pre-receive"
+ chmod 0700 "$HOME/evil-hooks/pre-receive"
+ printf "[core]\n\thooksPath = %s/evil-hooks\n" "$HOME" >"$HOME/.gitconfig"
+'
+
+mkdir "$work/testing"
(
- cd "$work/admin-repo"
+ cd "$work/testing"
+ git init -q -b main
git config user.name 'Gilti smoke test'
git config user.email 'smoke@gilti.invalid'
- cat >>conf/gitolite.conf <<'EOF'
-
-repo testing
- R = gitweb
-
-repo private
- RW+ = admin
-EOF
- git add conf/gitolite.conf
- git commit -m 'Publish testing repository' >/dev/null
- GIT_SSH_COMMAND="ssh $ssh_opts" git push origin HEAD >/dev/null
+ printf '%s\n' '# Testing' >README.md
+ git add README.md
+ git commit -m 'Initial commit' >/dev/null
+ git remote add origin "ssh://git@127.0.0.1:$ssh_port/testing"
+ GIT_SSH_COMMAND="ssh $ssh_opts" git push -u origin main >/dev/null
+)
+if "$engine" exec "$name" test -e /tmp/global-hook-ran; then
+ echo 'gilti-ssh honored the writable global Git configuration' >&2
+ exit 1
+fi
+repository_modes=$("$engine" exec "$name" stat -c '%a:%u:%g' \
+ /var/lib/gilti/git/repositories/testing.git \
+ /var/lib/gilti/git/repositories/testing.git/config)
+[ "$repository_modes" = "700:10000:10000
+600:10000:10000" ] || {
+ echo "unexpected repository ownership/modes: $repository_modes" >&2
+ exit 1
+}
+"$engine" exec --user 10000:10000 "$name" rm -rf \
+ /var/lib/gilti/git/.gitconfig /var/lib/gilti/git/evil-hooks
+GIT_SSH_COMMAND="ssh $ssh_opts_2" git clone \
+ "ssh://git@127.0.0.1:$ssh_port/testing" "$work/testing-clone" >/dev/null
+[ -f "$work/testing-clone/README.md" ]
+(
+ cd "$work/testing-clone"
+ git config user.name 'Gilti smoke test 2'
+ git config user.email 'smoke-2@gilti.invalid'
+ printf '%s\n' 'Second key can push.' >>README.md
+ git add README.md
+ git commit -m 'Push with second key' >/dev/null
+ GIT_SSH_COMMAND="ssh $ssh_opts_2" git push origin main >/dev/null
)
i=0
@@ -158,24 +222,26 @@ until curl -fsS "http://127.0.0.1:$http_port/" | grep -q 'testing'; do
[ "$i" -lt 30 ] || { "$engine" logs "$name" >&2; exit 1; }
sleep 1
done
-if curl -fsS "http://127.0.0.1:$http_port/" | grep -q 'private'; then
- echo 'private repository appeared in the cgit index' >&2
- exit 1
-fi
-status=$(curl -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:$http_port/private/")
-[ "$status" = 404 ] || {
- echo "private repository direct URL returned HTTP $status" >&2
- exit 1
-}
+curl -fsS "http://127.0.0.1:$http_port/testing/" >/dev/null
+
+# The running sshd uses its startup snapshot, not the mounted source file.
+cat "$work/admin.pub" >"$authorized_keys"
+# shellcheck disable=SC2086
+ssh $ssh_opts_2 git@127.0.0.1 | grep -q 'Gilti: authenticated'
fingerprint=$(ssh-keyscan -p "$ssh_port" 127.0.0.1 2>/dev/null | ssh-keygen -lf - | awk '{print $2}')
remove_container
-start without-key
+start
fingerprint_after=$(ssh-keyscan -p "$ssh_port" 127.0.0.1 2>/dev/null | ssh-keygen -lf - | awk '{print $2}')
[ "$fingerprint" = "$fingerprint_after" ] || {
echo 'SSH host key changed after restart' >&2
exit 1
}
curl -fsS "http://127.0.0.1:$http_port/" | grep -q 'testing'
-ssh -o BatchMode=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
- -i "$work/admin-2" -p "$ssh_port" git@127.0.0.1 info | grep -q 'hello admin'
+# shellcheck disable=SC2086
+ssh $ssh_opts git@127.0.0.1 | grep -q 'Gilti: authenticated'
+# shellcheck disable=SC2086
+if ssh $ssh_opts_2 git@127.0.0.1 >/dev/null 2>&1; then
+ echo 'removed SSH key was accepted after restart' >&2
+ exit 1
+fi