fabrica

hanna/fabrica

10076 bytes
Raw
1{
2 description = "fabrica — a self-hosted git server (single binary, HTTPS + SSH, themeable web UI)";
3
4 inputs = {
5 nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
6 flake-parts.url = "github:hercules-ci/flake-parts";
7 fenix = {
8 url = "github:nix-community/fenix";
9 inputs.nixpkgs.follows = "nixpkgs";
10 };
11 crane.url = "github:ipetkov/crane";
12 };
13
14 outputs =
15 inputs@{ flake-parts, ... }:
16 flake-parts.lib.mkFlake { inherit inputs; } {
17 systems = [
18 "x86_64-linux"
19 "aarch64-linux"
20 "x86_64-darwin"
21 "aarch64-darwin"
22 ];
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
140 perSystem =
141 { self'
142 , pkgs
143 , system
144 , ...
145 }:
146 let
147 # Nightly toolchain via fenix, pinned through flake.lock. The `complete`
148 # profile carries clippy, rustfmt, rust-src, and rust-analyzer.
149 fenixToolchain = inputs.fenix.packages.${system}.complete.toolchain;
150
151 craneLib = (inputs.crane.mkLib pkgs).overrideToolchain fenixToolchain;
152
153 # Keep files crane's default cargo-source filter would strip but the build
154 # needs: `.sql` migrations (embedded by `store` via `sqlx::migrate!`),
155 # `.snap` insta snapshots (read by `highlight`), and the web `assets/`
156 # (css/js/svg/… embedded by `web` via `rust-embed` at compile time).
157 src = pkgs.lib.cleanSourceWith {
158 src = ./.;
159 name = "source";
160 filter =
161 path: type:
162 (builtins.match ".*\\.(sql|snap|css|js|svg|ico|png|woff2)$" path != null)
163 || (craneLib.filterCargoSources path type);
164 };
165
166 commonArgs = {
167 inherit src;
168 strictDeps = true;
169 nativeBuildInputs = [
170 pkgs.pkg-config
171 pkgs.makeWrapper
172 # aws-lc-sys (russh's crypto backend) builds AWS-LC via cmake.
173 pkgs.cmake
174 ];
175 buildInputs = [
176 pkgs.libgit2
177 pkgs.zlib
178 pkgs.openssl
179 # inkjet compiles tree-sitter grammars (some C++), whose objects link
180 # libstdc++ dynamically; keep it in the closure so the built binary
181 # rpaths it and interactive `cargo test` finds it via LD_LIBRARY_PATH.
182 pkgs.stdenv.cc.cc.lib
183 ];
184 # Use the system libgit2 rather than the vendored copy.
185 LIBGIT2_NO_VENDOR = "1";
186 };
187
188 cargoArtifacts = craneLib.buildDepsOnly commonArgs;
189
190 fabrica = craneLib.buildPackage (
191 commonArgs
192 // {
193 inherit cargoArtifacts;
194 doCheck = false; # tests run as their own flake check
195 # The git plumbing must always be reachable at runtime.
196 postInstall = ''
197 wrapProgram $out/bin/fabrica \
198 --prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.git ]}
199 '';
200 }
201 );
202 in
203 {
204 packages = {
205 default = fabrica;
206 fabrica = fabrica;
207
208 container = pkgs.dockerTools.buildLayeredImage {
209 name = "fabrica";
210 tag = "latest";
211 contents = [
212 fabrica
213 pkgs.git
214 pkgs.cacert
215 ];
216 config = {
217 Entrypoint = [ "${fabrica}/bin/fabrica" ];
218 Cmd = [ "serve" ];
219 ExposedPorts = {
220 "8080/tcp" = { };
221 "2222/tcp" = { };
222 };
223 Volumes = {
224 "/var/lib/fabrica" = { };
225 };
226 };
227 };
228 };
229
230 checks = {
231 inherit fabrica;
232
233 clippy = craneLib.cargoClippy (
234 commonArgs
235 // {
236 inherit cargoArtifacts;
237 cargoClippyExtraArgs = "--workspace --all-targets -- -D warnings";
238 }
239 );
240
241 fmt = craneLib.cargoFmt {
242 src = craneLib.cleanCargoSource ./.;
243 };
244
245 test = craneLib.cargoNextest (
246 commonArgs
247 // {
248 inherit cargoArtifacts;
249 partitions = 1;
250 partitionType = "count";
251 # Crates have no tests during early scaffolding; do not treat an
252 # empty test set as a failure.
253 cargoNextestExtraArgs = "--no-tests=pass";
254 }
255 );
256
257 deny = craneLib.cargoDeny {
258 src = craneLib.cleanCargoSource ./.;
259 };
260
261 doc = craneLib.cargoDoc (
262 commonArgs
263 // {
264 inherit cargoArtifacts;
265 }
266 );
267 };
268
269 devShells.default = craneLib.devShell {
270 checks = self'.checks;
271 packages = [
272 pkgs.sqlx-cli
273 pkgs.sqlite
274 pkgs.postgresql
275 pkgs.git
276 pkgs.openssh
277 pkgs.gnupg
278 pkgs.cargo-nextest
279 pkgs.cargo-deny
280 pkgs.just
281 pkgs.pkg-config
282 pkgs.cmake
283 ];
284 buildInputs = commonArgs.buildInputs;
285 LIBGIT2_NO_VENDOR = "1";
286 # Test/bin artifacts built interactively link libgit2 dynamically but
287 # do not get the build sandbox's rpath, so the loader needs the lib
288 # dirs on the search path for `cargo test`/`cargo run` to work in the
289 # shell. (The Nix build embeds rpath itself, so its checks don't.)
290 LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath commonArgs.buildInputs;
291 };
292
293 formatter = pkgs.nixpkgs-fmt;
294 };
295 };
296}