Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
Cargo.lock +1 −0
| @@ -1671,6 +1671,7 @@ dependencies = [ | |||
| 1671 | "anyhow", | 1671 | "anyhow", |
| 1672 | "cli", | 1672 | "cli", |
| 1673 | "config", | 1673 | "config", |
| 1674 | "git", | ||
| 1674 | "ssh", | 1675 | "ssh", |
| 1675 | "store", | 1676 | "store", |
| 1676 | "tokio", | 1677 | "tokio", |
Cargo.toml +7 −0
| @@ -21,9 +21,16 @@ web = { path = "crates/web" } | |||
| 21 | ssh = { path = "crates/ssh" } | 21 | ssh = { path = "crates/ssh" } |
| 22 | config = { path = "crates/config" } | 22 | config = { path = "crates/config" } |
| 23 | store = { path = "crates/store" } | 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 | anyhow = { workspace = true } | 27 | anyhow = { workspace = true } |
| 25 | tokio = { workspace = true } | 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 | [lints] | 34 | [lints] |
| 28 | workspace = true | 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 | # Random key generation for the SSH signature fixtures (PrivateKey::random). | 32 | # Random key generation for the SSH signature fixtures (PrivateKey::random). |
| 33 | rand_core = { workspace = true } | 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 | [lints] | 41 | [lints] |
| 36 | workspace = true | 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 | "aarch64-darwin" | 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 | perSystem = | 140 | perSystem = |
| 25 | { self' | 141 | { self' |
| 26 | , pkgs | 142 | , pkgs |