diff options
Diffstat
| -rw-r--r-- | .github/workflows/build.yml | 45 | +39 −6 |
| -rw-r--r-- | Cargo.lock | 7 | +7 −0 |
| -rw-r--r-- | Cargo.toml | 1 | +1 −0 |
| -rw-r--r-- | Dockerfile | 28 | +16 −12 |
| -rw-r--r-- | README.md | 50 | +23 −27 |
| -rw-r--r-- | charts/gilti/Chart.yaml | 2 | +1 −1 |
| -rw-r--r-- | charts/gilti/README.md | 66 | +33 −33 |
| -rw-r--r-- | charts/gilti/templates/configmap.yaml | 10 | +9 −1 |
| -rw-r--r-- | charts/gilti/templates/deployment.yaml | 16 | +5 −11 |
| -rw-r--r-- | charts/gilti/values.schema.json | 7 | +5 −2 |
| -rw-r--r-- | charts/gilti/values.yaml | 6 | +3 −3 |
| -rw-r--r-- | config/cgitrc | 1 | +0 −1 |
| -rw-r--r-- | config/sshd_config | 7 | +5 −2 |
| -rw-r--r-- | crates/gilti-ssh/Cargo.toml | 17 | +17 −0 |
| -rw-r--r-- | crates/gilti-ssh/src/main.rs | 288 | +288 −0 |
| -rw-r--r-- | crates/gilti/Cargo.toml | 4 | +0 −4 |
| -rw-r--r-- | crates/gilti/src/main.rs | 13 | +5 −8 |
| -rw-r--r-- | deny.toml | 32 | +32 −0 |
| -rw-r--r-- | mise.lock | 6 | +5 −1 |
| -rw-r--r-- | mise.toml | 6 | +5 −1 |
| -rwxr-xr-x | scripts/entrypoint.sh | 153 | +63 −90 |
| -rwxr-xr-x | tests/chart.sh | 6 | +3 −3 |
| -rwxr-xr-x | tests/smoke.sh | 162 | +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 |
