Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +1 −0
| @@ -1671,6 +1671,7 @@ dependencies = [ | |||
| 1671 | 1671 | "anyhow", | |
| 1672 | 1672 | "cli", | |
| 1673 | 1673 | "config", | |
| 1674 | + | "git", | |
| 1674 | 1675 | "ssh", | |
| 1675 | 1676 | "store", | |
| 1676 | 1677 | "tokio", | |
Cargo.toml +7 −0
| @@ -21,9 +21,16 @@ web = { path = "crates/web" } | |||
| 21 | 21 | ssh = { path = "crates/ssh" } | |
| 22 | 22 | config = { path = "crates/config" } | |
| 23 | 23 | store = { path = "crates/store" } | |
| 24 | + | # Direct, optional dep only to expose git's `vendored` feature at the binary | |
| 25 | + | # (feature unification then applies it to the transitive `git` too). | |
| 26 | + | git = { path = "crates/git", optional = true } | |
| 24 | 27 | anyhow = { workspace = true } | |
| 25 | 28 | tokio = { workspace = true } | |
| 26 | 29 | ||
| 30 | + | [features] | |
| 31 | + | # Statically link libgit2 (for the Docker image, which has no system libgit2). | |
| 32 | + | vendored = ["dep:git", "git/vendored"] | |
| 33 | + | ||
| 27 | 34 | [lints] | |
| 28 | 35 | workspace = true | |
| 29 | 36 | ||
Dockerfile +54 −0
| @@ -0,0 +1,54 @@ | |||
| 1 | + | # This Source Code Form is subject to the terms of the Mozilla Public | |
| 2 | + | # License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | + | # file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | + | # | |
| 5 | + | # Multi-stage, no Nix. The `vendored` feature statically links libgit2, so the | |
| 6 | + | # runtime image needs no system libgit2. The Nix build (flake.nix) is the | |
| 7 | + | # alternative; `packages.container` produces an equivalent image natively. | |
| 8 | + | ||
| 9 | + | # ---- Builder ---- | |
| 10 | + | FROM rustlang/rust:nightly-slim AS builder | |
| 11 | + | ||
| 12 | + | # pkg-config + libssl-dev for native-tls (mail); cmake for aws-lc-sys (ssh) and | |
| 13 | + | # the vendored libgit2 build; git for build scripts. | |
| 14 | + | RUN apt-get update && apt-get install -y --no-install-recommends \ | |
| 15 | + | pkg-config libssl-dev cmake git ca-certificates \ | |
| 16 | + | && rm -rf /var/lib/apt/lists/* | |
| 17 | + | ||
| 18 | + | WORKDIR /build | |
| 19 | + | ||
| 20 | + | # Cache the dependency layer: build a stub crate graph first, then the real source. | |
| 21 | + | COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ | |
| 22 | + | COPY crates ./crates | |
| 23 | + | COPY src ./src | |
| 24 | + | COPY migrations ./migrations | |
| 25 | + | COPY assets ./assets | |
| 26 | + | ||
| 27 | + | RUN cargo build --release --features vendored \ | |
| 28 | + | && /build/target/release/fabrica --version | |
| 29 | + | ||
| 30 | + | # ---- Runtime ---- | |
| 31 | + | FROM debian:stable-slim | |
| 32 | + | ||
| 33 | + | # git is mandatory for pack transport; tini for signal handling; libssl3 for | |
| 34 | + | # native-tls. Verify git is present at build time so a missing plumbing binary is | |
| 35 | + | # a build failure, not a runtime surprise. | |
| 36 | + | RUN apt-get update && apt-get install -y --no-install-recommends \ | |
| 37 | + | git ca-certificates tini libssl3 \ | |
| 38 | + | && rm -rf /var/lib/apt/lists/* \ | |
| 39 | + | && git --version | |
| 40 | + | ||
| 41 | + | RUN useradd --uid 10001 --system --home-dir /var/lib/fabrica --shell /usr/sbin/nologin fabrica | |
| 42 | + | ||
| 43 | + | COPY --from=builder /build/target/release/fabrica /usr/local/bin/fabrica | |
| 44 | + | RUN fabrica --version && git --version | |
| 45 | + | ||
| 46 | + | USER fabrica | |
| 47 | + | VOLUME /var/lib/fabrica | |
| 48 | + | EXPOSE 8080 2222 | |
| 49 | + | ||
| 50 | + | HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \ | |
| 51 | + | CMD ["fabrica", "doctor", "--quiet"] | |
| 52 | + | ||
| 53 | + | ENTRYPOINT ["/usr/bin/tini", "--", "fabrica"] | |
| 54 | + | CMD ["serve"] | |
compose.yml +62 −0
| @@ -0,0 +1,62 @@ | |||
| 1 | + | # This Source Code Form is subject to the terms of the Mozilla Public | |
| 2 | + | # License, v. 2.0. If a copy of the MPL was not distributed with this | |
| 3 | + | # file, You can obtain one at https://mozilla.org/MPL/2.0/. | |
| 4 | + | # | |
| 5 | + | # Default path is pure SQLite; Postgres is behind the `postgres` profile | |
| 6 | + | # (`docker compose --profile postgres up`). | |
| 7 | + | ||
| 8 | + | services: | |
| 9 | + | fabrica: | |
| 10 | + | build: . | |
| 11 | + | # Or: image: ghcr.io/you/fabrica:latest | |
| 12 | + | restart: unless-stopped | |
| 13 | + | ports: | |
| 14 | + | - "8080:8080" | |
| 15 | + | # Map SSH. The advertised clone port is `ssh.clone_port` in the config, | |
| 16 | + | # which may differ from this published port (e.g. behind a forward). | |
| 17 | + | - "2222:2222" | |
| 18 | + | volumes: | |
| 19 | + | - data:/var/lib/fabrica | |
| 20 | + | - repos:/var/lib/fabrica/repos | |
| 21 | + | # Bind-mount the config; override a couple of keys via env below to | |
| 22 | + | # demonstrate both mechanisms. | |
| 23 | + | - ./fabrica.toml:/etc/fabrica/fabrica.toml:ro | |
| 24 | + | environment: | |
| 25 | + | # FABRICA__<SECTION>__<KEY> overrides the file (double underscores nest). | |
| 26 | + | FABRICA__INSTANCE__URL: "https://git.example.com" | |
| 27 | + | FABRICA__LOG__FORMAT: "json" | |
| 28 | + | healthcheck: | |
| 29 | + | test: ["CMD", "fabrica", "doctor", "--quiet"] | |
| 30 | + | interval: 30s | |
| 31 | + | timeout: 5s | |
| 32 | + | ||
| 33 | + | postgres: | |
| 34 | + | image: postgres:16-alpine | |
| 35 | + | profiles: ["postgres"] | |
| 36 | + | restart: unless-stopped | |
| 37 | + | environment: | |
| 38 | + | POSTGRES_USER: fabrica | |
| 39 | + | POSTGRES_PASSWORD: fabrica | |
| 40 | + | POSTGRES_DB: fabrica | |
| 41 | + | volumes: | |
| 42 | + | - pg:/var/lib/postgresql/data | |
| 43 | + | # To use it, point the database URL at this service, e.g.: | |
| 44 | + | # FABRICA__DATABASE__URL: "postgres://fabrica:fabrica@postgres/fabrica" | |
| 45 | + | ||
| 46 | + | volumes: | |
| 47 | + | data: | |
| 48 | + | repos: | |
| 49 | + | pg: | |
| 50 | + | ||
| 51 | + | # --- Reverse proxy note ------------------------------------------------------ | |
| 52 | + | # A proxy in front MUST NOT buffer the git pack routes, or clones stall. For | |
| 53 | + | # nginx: | |
| 54 | + | # | |
| 55 | + | # location / { | |
| 56 | + | # proxy_pass http://fabrica:8080; | |
| 57 | + | # proxy_http_version 1.1; | |
| 58 | + | # # Critical for `/git-upload-pack` and `/info/refs`: | |
| 59 | + | # proxy_buffering off; | |
| 60 | + | # proxy_request_buffering off; | |
| 61 | + | # proxy_read_timeout 3600s; | |
| 62 | + | # } | |
crates/git/Cargo.toml +6 −0
| @@ -32,5 +32,11 @@ tempfile = { workspace = true } | |||
| 32 | 32 | # Random key generation for the SSH signature fixtures (PrivateKey::random). | |
| 33 | 33 | rand_core = { workspace = true } | |
| 34 | 34 | ||
| 35 | + | [features] | |
| 36 | + | # Build libgit2 from source and link it statically, so the runtime image needs no | |
| 37 | + | # system libgit2. Used by the Dockerfile (`--features vendored`); the Nix build | |
| 38 | + | # uses the system libgit2 instead (LIBGIT2_NO_VENDOR=1). | |
| 39 | + | vendored = ["git2/vendored-libgit2"] | |
| 40 | + | ||
| 35 | 41 | [lints] | |
| 36 | 42 | workspace = true | |
docs/deployment.md +102 −0
| @@ -0,0 +1,102 @@ | |||
| 1 | + | # Deployment | |
| 2 | + | ||
| 3 | + | fabrica is one binary, one config file, one data directory. It needs the `git` | |
| 4 | + | binary (≥ 2.41) on `PATH` at runtime — every deployment path below guarantees it. | |
| 5 | + | ||
| 6 | + | ## Configuration | |
| 7 | + | ||
| 8 | + | Copy `fabrica.example.toml`, edit it, and point the binary at it with `--config` | |
| 9 | + | (or place it at `/etc/fabrica/fabrica.toml` or `$XDG_CONFIG_HOME/fabrica/`). Every | |
| 10 | + | key can be overridden by an environment variable `FABRICA__<SECTION>__<KEY>` | |
| 11 | + | (double underscores nest), and any `*_file` key reads its value from a file (for | |
| 12 | + | agenix / systemd-creds / Docker secrets). Validate with `fabrica config check`; | |
| 13 | + | inspect the merged result (secrets redacted) with `fabrica config show`. | |
| 14 | + | ||
| 15 | + | On first run fabrica generates its HS256 secret (`auth.secret_file`, default | |
| 16 | + | `{data_dir}/secret`, mode 0600) and its SSH host key (`ssh.host_key_path`). | |
| 17 | + | ||
| 18 | + | ## Docker / Compose | |
| 19 | + | ||
| 20 | + | `docker compose up` builds the image (`Dockerfile`) and starts fabrica on `:8080` | |
| 21 | + | (HTTP) and `:2222` (SSH), with named volumes for the data and repos. The default | |
| 22 | + | database is SQLite; add `--profile postgres` to bring up Postgres and point | |
| 23 | + | `FABRICA__DATABASE__URL` at it. | |
| 24 | + | ||
| 25 | + | The image is multi-stage: the builder compiles with `--features vendored` (libgit2 | |
| 26 | + | is statically linked), so the runtime (`debian:stable-slim`) needs only `git`, | |
| 27 | + | `ca-certificates`, `tini`, and `libssl3`. It runs as a non-root user, `git | |
| 28 | + | --version` is verified at build time, and the health check is `fabrica doctor | |
| 29 | + | --quiet`. | |
| 30 | + | ||
| 31 | + | ### Reverse proxy — read this | |
| 32 | + | ||
| 33 | + | A proxy in front **MUST NOT buffer** the git pack routes, or clones stall. For | |
| 34 | + | nginx: | |
| 35 | + | ||
| 36 | + | ```nginx | |
| 37 | + | location / { | |
| 38 | + | proxy_pass http://127.0.0.1:8080; | |
| 39 | + | proxy_http_version 1.1; | |
| 40 | + | proxy_buffering off; # critical for /info/refs and /git-upload-pack | |
| 41 | + | proxy_request_buffering off; | |
| 42 | + | proxy_read_timeout 3600s; | |
| 43 | + | } | |
| 44 | + | ``` | |
| 45 | + | ||
| 46 | + | Set `server.behind_proxy = true` so `X-Forwarded-For`/`-Proto` are trusted, and | |
| 47 | + | `auth.cookie_secure = true` once you terminate TLS at the proxy. | |
| 48 | + | ||
| 49 | + | ## NixOS | |
| 50 | + | ||
| 51 | + | The flake exposes `nixosModules.default`: | |
| 52 | + | ||
| 53 | + | ```nix | |
| 54 | + | { | |
| 55 | + | inputs.fabrica.url = "github:you/fabrica"; | |
| 56 | + | # … | |
| 57 | + | nixosConfigurations.myhost = nixpkgs.lib.nixosSystem { | |
| 58 | + | modules = [ | |
| 59 | + | fabrica.nixosModules.default | |
| 60 | + | { | |
| 61 | + | services.fabrica = { | |
| 62 | + | enable = true; | |
| 63 | + | openFirewall = true; | |
| 64 | + | environmentFile = "/run/secrets/fabrica.env"; # FABRICA__* secrets | |
| 65 | + | settings = { | |
| 66 | + | instance.url = "https://git.example.com"; | |
| 67 | + | instance.name = "our forge"; | |
| 68 | + | server.port = 8080; | |
| 69 | + | ssh.port = 2222; | |
| 70 | + | database.url = "sqlite:///var/lib/fabrica/fabrica.db"; | |
| 71 | + | }; | |
| 72 | + | }; | |
| 73 | + | } | |
| 74 | + | ]; | |
| 75 | + | }; | |
| 76 | + | } | |
| 77 | + | ``` | |
| 78 | + | ||
| 79 | + | The module renders `settings` to a TOML file, runs a hardened `systemd` unit | |
| 80 | + | (`DynamicUser` off, `StateDirectory=fabrica`, `ProtectSystem=strict`, | |
| 81 | + | `NoNewPrivileges`, `CAP_NET_BIND_SERVICE` only when a privileged port is | |
| 82 | + | configured), and opens the firewall when asked. The package wraps the binary with | |
| 83 | + | `git` on `PATH`. | |
| 84 | + | ||
| 85 | + | ## Nix container | |
| 86 | + | ||
| 87 | + | `nix build .#container` produces an OCI image (`dockerTools.buildLayeredImage`) | |
| 88 | + | with `git` and `ca-certificates` in the closure — a Nix-native alternative to the | |
| 89 | + | `Dockerfile`. Load it with `docker load < result`. | |
| 90 | + | ||
| 91 | + | ## First user | |
| 92 | + | ||
| 93 | + | There is no web sign-up. Create the first account from the CLI (the same binary): | |
| 94 | + | ||
| 95 | + | ```sh | |
| 96 | + | fabrica --config /etc/fabrica/fabrica.toml user add alice alice@example.com --admin | |
| 97 | + | # prints an activation link; or set a password directly: | |
| 98 | + | fabrica --config /etc/fabrica/fabrica.toml user passwd alice | |
| 99 | + | fabrica --config /etc/fabrica/fabrica.toml key add --type ssh alice @~/.ssh/id_ed25519.pub | |
| 100 | + | ``` | |
| 101 | + | ||
| 102 | + | Run `fabrica doctor` to confirm git, the config, and the database are healthy. | |
flake.nix +116 −0
| @@ -21,6 +21,122 @@ | |||
| 21 | 21 | "aarch64-darwin" | |
| 22 | 22 | ]; | |
| 23 | 23 | ||
| 24 | + | # NixOS module: services.fabrica. A hardened systemd unit, a TOML-typed | |
| 25 | + | # `settings` option, secrets via `environmentFile`, and optional firewall | |
| 26 | + | # opening. `git` is wrapped onto the service PATH by the package itself. | |
| 27 | + | flake.nixosModules.default = | |
| 28 | + | { config, lib, pkgs, ... }: | |
| 29 | + | let | |
| 30 | + | cfg = config.services.fabrica; | |
| 31 | + | format = pkgs.formats.toml { }; | |
| 32 | + | configFile = format.generate "fabrica.toml" cfg.settings; | |
| 33 | + | in | |
| 34 | + | { | |
| 35 | + | options.services.fabrica = { | |
| 36 | + | enable = lib.mkEnableOption "the fabrica git server"; | |
| 37 | + | ||
| 38 | + | package = lib.mkOption { | |
| 39 | + | type = lib.types.package; | |
| 40 | + | default = inputs.self.packages.${pkgs.stdenv.hostPlatform.system}.default; | |
| 41 | + | description = "The fabrica package to run."; | |
| 42 | + | }; | |
| 43 | + | ||
| 44 | + | user = lib.mkOption { | |
| 45 | + | type = lib.types.str; | |
| 46 | + | default = "fabrica"; | |
| 47 | + | description = "User the service runs as."; | |
| 48 | + | }; | |
| 49 | + | ||
| 50 | + | group = lib.mkOption { | |
| 51 | + | type = lib.types.str; | |
| 52 | + | default = "fabrica"; | |
| 53 | + | description = "Group the service runs as."; | |
| 54 | + | }; | |
| 55 | + | ||
| 56 | + | dataDir = lib.mkOption { | |
| 57 | + | type = lib.types.path; | |
| 58 | + | default = "/var/lib/fabrica"; | |
| 59 | + | description = "State directory (repos, database, host key, secret)."; | |
| 60 | + | }; | |
| 61 | + | ||
| 62 | + | environmentFile = lib.mkOption { | |
| 63 | + | type = lib.types.nullOr lib.types.path; | |
| 64 | + | default = null; | |
| 65 | + | description = '' | |
| 66 | + | Path to an EnvironmentFile with secrets as FABRICA__* variables | |
| 67 | + | (e.g. agenix/systemd-creds output). Kept out of the Nix store. | |
| 68 | + | ''; | |
| 69 | + | }; | |
| 70 | + | ||
| 71 | + | openFirewall = lib.mkOption { | |
| 72 | + | type = lib.types.bool; | |
| 73 | + | default = false; | |
| 74 | + | description = "Open the HTTP and SSH ports in the firewall."; | |
| 75 | + | }; | |
| 76 | + | ||
| 77 | + | settings = lib.mkOption { | |
| 78 | + | inherit (format) type; | |
| 79 | + | default = { }; | |
| 80 | + | description = "Configuration rendered to fabrica.toml."; | |
| 81 | + | example = { | |
| 82 | + | instance.url = "https://git.example.com"; | |
| 83 | + | server.port = 8080; | |
| 84 | + | ssh.port = 2222; | |
| 85 | + | }; | |
| 86 | + | }; | |
| 87 | + | }; | |
| 88 | + | ||
| 89 | + | config = lib.mkIf cfg.enable { | |
| 90 | + | users.users.${cfg.user} = { | |
| 91 | + | isSystemUser = true; | |
| 92 | + | group = cfg.group; | |
| 93 | + | home = cfg.dataDir; | |
| 94 | + | }; | |
| 95 | + | users.groups.${cfg.group} = { }; | |
| 96 | + | ||
| 97 | + | networking.firewall = lib.mkIf cfg.openFirewall { | |
| 98 | + | allowedTCPPorts = [ | |
| 99 | + | (cfg.settings.server.port or 8080) | |
| 100 | + | (cfg.settings.ssh.port or 2222) | |
| 101 | + | ]; | |
| 102 | + | }; | |
| 103 | + | ||
| 104 | + | systemd.services.fabrica = { | |
| 105 | + | description = "fabrica git server"; | |
| 106 | + | wantedBy = [ "multi-user.target" ]; | |
| 107 | + | after = [ "network.target" ]; | |
| 108 | + | serviceConfig = { | |
| 109 | + | ExecStart = "${cfg.package}/bin/fabrica --config ${configFile} serve"; | |
| 110 | + | User = cfg.user; | |
| 111 | + | Group = cfg.group; | |
| 112 | + | StateDirectory = "fabrica"; | |
| 113 | + | WorkingDirectory = cfg.dataDir; | |
| 114 | + | Restart = "on-failure"; | |
| 115 | + | EnvironmentFile = lib.mkIf (cfg.environmentFile != null) cfg.environmentFile; | |
| 116 | + | ||
| 117 | + | # Hardening. | |
| 118 | + | DynamicUser = false; | |
| 119 | + | NoNewPrivileges = true; | |
| 120 | + | ProtectSystem = "strict"; | |
| 121 | + | ProtectHome = true; | |
| 122 | + | PrivateTmp = true; | |
| 123 | + | PrivateDevices = true; | |
| 124 | + | ProtectKernelTunables = true; | |
| 125 | + | ProtectKernelModules = true; | |
| 126 | + | ProtectControlGroups = true; | |
| 127 | + | RestrictSUIDSGID = true; | |
| 128 | + | LockPersonality = true; | |
| 129 | + | ReadWritePaths = [ cfg.dataDir ]; | |
| 130 | + | # Only if a privileged port is configured. | |
| 131 | + | AmbientCapabilities = lib.mkIf | |
| 132 | + | ((cfg.settings.server.port or 8080) < 1024 | |
| 133 | + | || (cfg.settings.ssh.port or 2222) < 1024) | |
| 134 | + | [ "CAP_NET_BIND_SERVICE" ]; | |
| 135 | + | }; | |
| 136 | + | }; | |
| 137 | + | }; | |
| 138 | + | }; | |
| 139 | + | ||
| 24 | 140 | perSystem = | |
| 25 | 141 | { self' | |
| 26 | 142 | , pkgs | |