From de2056fce51ec21d91f42ef9acd6fa379928ac5a Mon Sep 17 00:00:00 2001 From: "Adam C. Stephens" Date: Tue, 25 Aug 2026 22:30:11 -0400 Subject: [PATCH] feat: nix package and NixOS module Refs: #22 Assisted-By: Claude Opus 5 --- config/runtime.exs | 20 +++++--- flake.nix | 20 +++++++- nix/module.nix | 118 +++++++++++++++++++++++++++++++++++++++++++++ nix/package.nix | 70 +++++++++++++++++++++++++++ nix/test.nix | 29 +++++++++++ 5 files changed, 249 insertions(+), 8 deletions(-) create mode 100644 nix/module.nix create mode 100644 nix/package.nix create mode 100644 nix/test.nix diff --git a/config/runtime.exs b/config/runtime.exs index e71cca0..39d8b55 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -81,15 +81,21 @@ if config_env() == :prod do host = System.get_env("PHX_HOST") || "example.com" + bind_address = + System.get_env("BIND_ADDRESS", "::") + |> String.to_charlist() + |> :inet.parse_address() + |> case do + {:ok, address} -> + address + + {:error, :einval} -> + raise "environment variable BIND_ADDRESS is not a valid IP address" + end + config :git_sync, GitSyncWeb.Endpoint, url: [host: host, port: 443, scheme: "https"], - http: [ - # Enable IPv6 and bind on all interfaces. - # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. - # See the documentation on https://bandit.hexdocs.pm/Bandit.html#t:options/0 - # for details about using IPv6 vs IPv4 and loopback vs public addresses. - ip: {0, 0, 0, 0, 0, 0, 0, 0} - ], + http: [ip: bind_address], secret_key_base: secret_key_base # ## SSL Support diff --git a/flake.nix b/flake.nix index 411c98a..11ff13b 100644 --- a/flake.nix +++ b/flake.nix @@ -9,13 +9,20 @@ flake-parts.lib.mkFlake { inherit inputs; } { imports = [ ]; + flake.nixosModules.default = ./nix/module.nix; + systems = [ "x86_64-linux" "aarch64-darwin" ]; perSystem = - { lib, pkgs, ... }: + { + config, + lib, + pkgs, + ... + }: let beamPackages = pkgs.beamMinimal29Packages.overrideScope ( _: prev: { @@ -24,6 +31,17 @@ ); in { + packages.default = pkgs.callPackage ./nix/package.nix { }; + + checks = lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux { + module = pkgs.testers.runNixOSTest ( + import ./nix/test.nix { + module = ./nix/module.nix; + package = config.packages.default; + } + ); + }; + devShells.default = pkgs.mkShell { packages = [ beamPackages.erlang diff --git a/nix/module.nix b/nix/module.nix new file mode 100644 index 0000000..48e7cb1 --- /dev/null +++ b/nix/module.nix @@ -0,0 +1,118 @@ +{ + config, + lib, + pkgs, + ... +}: + +let + cfg = config.services.git-sync; +in +{ + options.services.git-sync = { + enable = lib.mkEnableOption "git-sync, a git repository mirroring service"; + + package = lib.mkOption { + type = lib.types.package; + default = pkgs.callPackage ./package.nix { }; + defaultText = lib.literalExpression "pkgs.callPackage ./package.nix { }"; + description = "The git-sync package to run."; + }; + + host = lib.mkOption { + type = lib.types.str; + description = "Public hostname the service is reached at, used to build OAuth redirect URIs."; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 4000; + description = "Port the HTTP endpoint listens on."; + }; + + address = lib.mkOption { + type = lib.types.str; + default = "::1"; + description = "Address the HTTP endpoint binds to."; + }; + + secretsFile = lib.mkOption { + type = lib.types.path; + description = '' + EnvironmentFile holding `SECRET_KEY_BASE` and `CLOAK_KEY`. Generate them with + `mix phx.gen.secret` and + `elixir -e 'IO.puts(Base.encode64(:crypto.strong_rand_bytes(32)))'`. + ''; + }; + + openFirewall = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether to open {option}`services.git-sync.port` in the firewall."; + }; + }; + + config = lib.mkIf cfg.enable { + networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ]; + + systemd.services.git-sync = { + description = "git-sync repository mirroring"; + wantedBy = [ "multi-user.target" ]; + after = [ "network-online.target" ]; + wants = [ "network-online.target" ]; + + environment = { + PHX_SERVER = "true"; + PHX_HOST = cfg.host; + PORT = toString cfg.port; + BIND_ADDRESS = cfg.address; + DATABASE_PATH = "/var/lib/git-sync/git_sync.db"; + WORKSPACE_ROOT = "/var/lib/git-sync/workspaces"; + HOME = "/var/lib/git-sync"; + RELEASE_TMP = "/run/git-sync"; + RELEASE_DISTRIBUTION = "none"; + RELEASE_COOKIE = "git-sync"; + }; + + serviceConfig = { + Type = "exec"; + ExecStart = "${lib.getExe cfg.package} start"; + EnvironmentFile = cfg.secretsFile; + Restart = "on-failure"; + + DynamicUser = true; + StateDirectory = "git-sync"; + RuntimeDirectory = "git-sync"; + WorkingDirectory = "/var/lib/git-sync"; + + CapabilityBoundingSet = [ "" ]; + LockPersonality = true; + NoNewPrivileges = true; + PrivateDevices = true; + PrivateTmp = true; + ProtectClock = true; + ProtectControlGroups = true; + ProtectHome = true; + ProtectHostname = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + ProtectSystem = "strict"; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + "AF_UNIX" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ + "@system-service" + "~@privileged" + ]; + }; + }; + }; +} diff --git a/nix/package.nix b/nix/package.nix new file mode 100644 index 0000000..a894737 --- /dev/null +++ b/nix/package.nix @@ -0,0 +1,70 @@ +{ + bash, + lib, + beamMinimal29Packages, + esbuild, + git, + makeWrapper, + openssh, +}: + +let + beamPackages = beamMinimal29Packages.overrideScope ( + _: prev: { + elixir = prev.elixir_1_20; + } + ); +in +beamPackages.mixRelease rec { + pname = "git-sync"; + version = "0.1.0"; + + MIX_ESBUILD_PATH = lib.getExe esbuild; + + src = lib.fileset.toSource { + root = ../.; + fileset = lib.fileset.unions [ + ../assets + ../config + ../lib + ../priv + ../mix.exs + ../mix.lock + ]; + }; + + mixFodDeps = beamPackages.fetchMixDeps { + pname = "${pname}-deps"; + inherit src version MIX_ESBUILD_PATH; + hash = "sha256-dU5V1UQ/ztU5LQVR4e2/fCupJihKVEpZJOezdqK+s2M="; + }; + + nativeBuildInputs = [ makeWrapper ]; + + # exqlite otherwise tries to download a precompiled NIF into a cache under $HOME. + FORCE_BUILD = "1"; + + preConfigure = '' + export ELIXIR_MAKE_CACHE_DIR=$TEMPDIR/elixir_make + ''; + + preBuild = '' + mix assets.deploy --no-deps-check + ''; + + postFixup = '' + wrapProgram $out/bin/git_sync \ + --prefix PATH : ${ + lib.makeBinPath [ + bash + git + openssh + ] + } + ''; + + meta = { + description = "Mirror git repositories between forges"; + mainProgram = "git_sync"; + }; +} diff --git a/nix/test.nix b/nix/test.nix new file mode 100644 index 0000000..4defbe4 --- /dev/null +++ b/nix/test.nix @@ -0,0 +1,29 @@ +{ module, package }: +{ lib, ... }: +{ + name = "git-sync"; + + nodes.machine = + { config, pkgs, ... }: + { + imports = [ module ]; + + services.git-sync = { + enable = true; + package = package; + host = "git-sync.example.com"; + address = "127.0.0.1"; + secretsFile = pkgs.writeText "git-sync.env" '' + SECRET_KEY_BASE=${lib.strings.replicate 64 "a"} + CLOAK_KEY=${lib.strings.replicate 43 "b"}= + ''; + }; + }; + + testScript = '' + machine.wait_for_unit("git-sync.service") + machine.wait_for_open_port(4000) + machine.succeed("curl --fail --show-error http://127.0.0.1:4000/") + machine.succeed("test -f /var/lib/git-sync/git_sync.db") + ''; +} -- 2.54.0