diff --git a/CHANGELOG.md b/CHANGELOG.md index b728640..4c0ba04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Changes yet to be released are documented here. +- Fix path expansion when running in systemd +- Fix bug in netrc writing +- Add nixos, home-manager, and darwin-nix modules + ## v0.1.0 Initial release. diff --git a/flake.nix b/flake.nix index e1c0ef9..72df013 100644 --- a/flake.nix +++ b/flake.nix @@ -6,6 +6,7 @@ let systems = [ "x86_64-linux" + "aarch64-linux" "aarch64-darwin" ]; forAllSystems = fn: nixpkgs.lib.genAttrs systems (system: fn nixpkgs.legacyPackages.${system}); @@ -23,18 +24,24 @@ }; }); - checks = forAllSystems (pkgs: { - tests = self.packages.${pkgs.stdenv.hostPlatform.system}.default.overrideAttrs (_: { - pname = "nix-cache-login-tests"; - checkPhase = '' - runHook preCheck - go test ./... - runHook postCheck - ''; - doCheck = true; - }); - devShell = self.devShells.${pkgs.stdenv.hostPlatform.system}.default; - }); + checks = forAllSystems ( + pkgs: + { + tests = self.packages.${pkgs.stdenv.hostPlatform.system}.default.overrideAttrs (_: { + pname = "nix-cache-login-tests"; + checkPhase = '' + runHook preCheck + go test ./... + runHook postCheck + ''; + doCheck = true; + }); + devShell = self.devShells.${pkgs.stdenv.hostPlatform.system}.default; + } + // pkgs.lib.optionalAttrs pkgs.stdenv.isLinux { + nixos-module = pkgs.testers.runNixOSTest (import ./nixos-test.nix self); + } + ); devShells = forAllSystems (pkgs: { default = pkgs.mkShell { @@ -44,5 +51,54 @@ ]; }; }); + + homeModules = { + # Workstation (Linux + macOS): home-manager module running `nix-cache-login refresh` + default = + { + config, + lib, + pkgs, + ... + }: + { + imports = [ ./home-module.nix ]; + services.nix-cache-login.package = + lib.mkDefault + self.packages.${pkgs.stdenv.hostPlatform.system}.default; + }; + }; + + nixosModules = { + # Workstation: systemd user timer+service running `nix-cache-login refresh` + default = + { + config, + lib, + pkgs, + ... + }: + { + imports = [ ./nixos-module.nix ]; + services.nix-cache-login.package = + lib.mkDefault + self.packages.${pkgs.stdenv.hostPlatform.system}.default; + }; + + # Server: system-level timer+service running `nix-cache-login service-account` + server = + { + config, + lib, + pkgs, + ... + }: + { + imports = [ ./nixos-module-server.nix ]; + services.nix-cache-login-server.package = + lib.mkDefault + self.packages.${pkgs.stdenv.hostPlatform.system}.default; + }; + }; }; } diff --git a/go.mod b/go.mod index 78ab0a1..423298f 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module guardianproject.dev/ops/nix-cache-login -go 1.25.7 +go 1.25.0 require ( github.com/adrg/xdg v0.5.3 diff --git a/home-module.nix b/home-module.nix new file mode 100644 index 0000000..572366e --- /dev/null +++ b/home-module.nix @@ -0,0 +1,63 @@ +{ + config, + lib, + ... +}: +let + cfg = config.services.nix-cache-login; +in +{ + options.services.nix-cache-login = { + enable = lib.mkEnableOption "nix-cache-login automatic token refresh"; + package = lib.mkOption { + type = lib.types.package; + description = "The nix-cache-login package to use."; + }; + refreshInterval = lib.mkOption { + type = lib.types.ints.positive; + default = 900; + description = '' + How often to attempt token refresh, in seconds. + If no valid session exists, the service logs an error and retries on + the next interval. Run {command}`nix-cache-login` to log in. + ''; + example = 1800; + }; + }; + + config = lib.mkIf cfg.enable { + nix.settings.netrc-file = "${config.xdg.configHome}/nix/netrc"; + home.packages = [ cfg.package ]; + systemd.user.services.nix-cache-login = { + Unit.Description = "Nix cache login - refresh access token"; + Service = { + Type = "oneshot"; + ExecStart = "${cfg.package}/bin/nix-cache-login refresh"; + }; + }; + + systemd.user.timers.nix-cache-login = { + Unit.Description = "Nix cache login - periodic token refresh"; + Timer = { + OnBootSec = "2min"; + OnUnitActiveSec = "${toString cfg.refreshInterval}s"; + }; + Install.WantedBy = [ "timers.target" ]; + }; + + launchd.agents.nix-cache-login = { + enable = true; + config = { + ProgramArguments = [ + "${cfg.package}/bin/nix-cache-login" + "refresh" + ]; + StartInterval = cfg.refreshInterval; + RunAtLoad = true; + ProcessType = "Background"; + StandardOutPath = "${config.home.homeDirectory}/Library/Logs/nix-cache-login.log"; + StandardErrorPath = "${config.home.homeDirectory}/Library/Logs/nix-cache-login.log"; + }; + }; + }; +} diff --git a/internal/config/config.go b/internal/config/config.go index 77faaa1..cc37736 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -37,8 +37,8 @@ func Load(path string) (*Config, error) { return nil, fmt.Errorf("parsing config file: %w", err) } - cfg.NetrcPath = os.ExpandEnv(cfg.NetrcPath) - cfg.ClientSecretFile = os.ExpandEnv(cfg.ClientSecretFile) + cfg.NetrcPath = expandPath(cfg.NetrcPath) + cfg.ClientSecretFile = expandPath(cfg.ClientSecretFile) if cfg.ClientSecretFile != "" { secret, err := os.ReadFile(cfg.ClientSecretFile) @@ -71,6 +71,29 @@ func (c *Config) validate() error { return nil } +// expandPath expands environment variables in a path. XDG base directory +// variables are resolved using the xdg library, which applies the XDG spec +// fallbacks (e.g. $HOME/.config when $XDG_CONFIG_HOME is unset). This ensures +// correct behaviour in systemd user services, which do not set XDG variables. +func expandPath(s string) string { + return os.Expand(s, func(key string) string { + switch key { + case "XDG_CONFIG_HOME": + return xdg.ConfigHome + case "XDG_DATA_HOME": + return xdg.DataHome + case "XDG_CACHE_HOME": + return xdg.CacheHome + case "XDG_STATE_HOME": + return xdg.StateHome + case "XDG_RUNTIME_DIR": + return xdg.RuntimeDir + default: + return os.Getenv(key) + } + }) +} + // RefreshTokenPath returns the path to the stored refresh token. func RefreshTokenPath() string { return filepath.Join(xdg.ConfigHome, "nix-cache-login", "refresh-token") diff --git a/internal/netrc/netrc.go b/internal/netrc/netrc.go index f020a4d..6eaae73 100644 --- a/internal/netrc/netrc.go +++ b/internal/netrc/netrc.go @@ -25,7 +25,7 @@ func Upsert(path, machine, password string) error { } } if !found { - entries = append(entries, entry{machine: machine, password: password}) + entries = append(entries, entry{machine: machine, login: "dummy", password: password}) } return write(path, entries) @@ -71,6 +71,7 @@ func GetPassword(path, machine string) (string, error) { type entry struct { machine string + login string password string } @@ -102,6 +103,10 @@ func parse(path string) ([]entry, error) { entries = append(entries, *current) } current = &entry{machine: fields[1]} + case "login": + if current != nil { + current.login = fields[1] + } case "password": if current != nil { current.password = fields[1] @@ -129,7 +134,7 @@ func write(path string, entries []entry) error { if i > 0 { b.WriteString("\n") } - fmt.Fprintf(&b, "machine %s\npassword %s\n", e.machine, e.password) + fmt.Fprintf(&b, "machine %s\nlogin %s\npassword %s\n", e.machine, e.login, e.password) } if err := os.WriteFile(path, []byte(b.String()), 0600); err != nil { diff --git a/internal/netrc/netrc_test.go b/internal/netrc/netrc_test.go index 611c8c4..e8a0b51 100644 --- a/internal/netrc/netrc_test.go +++ b/internal/netrc/netrc_test.go @@ -27,7 +27,7 @@ func TestUpsertUpdateExisting(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "netrc") - initial := "machine other.host\npassword otherpass\n\nmachine cache.example.com\npassword oldtoken\n" + initial := "machine other.host\nlogin dummy\npassword otherpass\n\nmachine cache.example.com\nlogin dummy\npassword oldtoken\n" if err := os.WriteFile(path, []byte(initial), 0600); err != nil { t.Fatal(err) } @@ -59,7 +59,7 @@ func TestUpsertAppend(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "netrc") - initial := "machine existing.host\npassword existingpass\n" + initial := "machine existing.host\nlogin dummy\npassword existingpass\n" if err := os.WriteFile(path, []byte(initial), 0600); err != nil { t.Fatal(err) } @@ -89,7 +89,7 @@ func TestRemove(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "netrc") - initial := "machine keep.host\npassword keeppass\n\nmachine remove.host\npassword removepass\n" + initial := "machine keep.host\nlogin dummy\npassword keeppass\n\nmachine remove.host\nlogin dummy\npassword removepass\n" if err := os.WriteFile(path, []byte(initial), 0600); err != nil { t.Fatal(err) } @@ -141,7 +141,7 @@ func TestGetPasswordNotFound(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "netrc") - content := "machine other.host\npassword otherpass\n" + content := "machine other.host\nlogin dummy\npassword otherpass\n" if err := os.WriteFile(path, []byte(content), 0600); err != nil { t.Fatal(err) } @@ -179,7 +179,7 @@ func TestFilePermissionsCorrected(t *testing.T) { path := filepath.Join(dir, "netrc") // Create file with overly permissive mode - if err := os.WriteFile(path, []byte("machine old.host\npassword oldpass\n"), 0644); err != nil { + if err := os.WriteFile(path, []byte("machine old.host\nlogin dummy\npassword oldpass\n"), 0644); err != nil { t.Fatal(err) } diff --git a/nixos-module-server.nix b/nixos-module-server.nix new file mode 100644 index 0000000..85300b9 --- /dev/null +++ b/nixos-module-server.nix @@ -0,0 +1,50 @@ +{ config, lib, ... }: +let + cfg = config.services.nix-cache-login-server; +in +{ + options.services.nix-cache-login-server = { + enable = lib.mkEnableOption "nix-cache-login service-account token refresh"; + package = lib.mkOption { + type = lib.types.package; + description = "The nix-cache-login package to use."; + }; + configFile = lib.mkOption { + type = lib.types.path; + description = '' + Path to the nix-cache-login config.toml file. Must include + client_secret_file pointing to a readable credentials file. + ''; + example = "/etc/nix-cache-login/config.toml"; + }; + refreshInterval = lib.mkOption { + type = lib.types.str; + default = "15min"; + description = '' + Interval between token refresh attempts, as a systemd time span. + On failure the service logs an error and the timer retries on schedule. + ''; + example = "1h"; + }; + }; + + config = lib.mkIf cfg.enable { + environment.systemPackages = [ cfg.package ]; + systemd.services.nix-cache-login = { + description = "Nix cache login - service account token refresh"; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${cfg.package}/bin/nix-cache-login --config ${cfg.configFile} service-account"; + }; + }; + + systemd.timers.nix-cache-login = { + description = "Nix cache login - periodic service account token refresh"; + timerConfig = { + OnBootSec = "2min"; + OnUnitActiveSec = cfg.refreshInterval; + }; + wantedBy = [ "timers.target" ]; + }; + }; +} diff --git a/nixos-module.nix b/nixos-module.nix new file mode 100644 index 0000000..d2ad4e7 --- /dev/null +++ b/nixos-module.nix @@ -0,0 +1,43 @@ +{ config, lib, ... }: +let + cfg = config.services.nix-cache-login; +in +{ + options.services.nix-cache-login = { + enable = lib.mkEnableOption "nix-cache-login automatic token refresh"; + package = lib.mkOption { + type = lib.types.package; + description = "The nix-cache-login package to use."; + }; + refreshInterval = lib.mkOption { + type = lib.types.str; + default = "15min"; + description = '' + Interval between token refresh attempts, as a systemd time span. + If no valid session exists, the service logs an error and the timer + retries on the next interval. Run nix-cache-login to log in. + ''; + example = "1h"; + }; + }; + + config = lib.mkIf cfg.enable { + environment.systemPackages = [ cfg.package ]; + systemd.user.services.nix-cache-login = { + description = "Nix cache login - refresh access token"; + serviceConfig = { + Type = "oneshot"; + ExecStart = "${cfg.package}/bin/nix-cache-login refresh"; + }; + }; + + systemd.user.timers.nix-cache-login = { + description = "Nix cache login - periodic token refresh"; + timerConfig = { + OnBootSec = "2min"; + OnUnitActiveSec = cfg.refreshInterval; + }; + wantedBy = [ "timers.target" ]; + }; + }; +} diff --git a/nixos-test.nix b/nixos-test.nix new file mode 100644 index 0000000..9c5840f --- /dev/null +++ b/nixos-test.nix @@ -0,0 +1,29 @@ +self: { + name = "nix-cache-login-nixos-module"; + + nodes.machine = + { ... }: + { + imports = [ self.nixosModules.default ]; + services.nix-cache-login.enable = true; + }; + + testScript = '' + machine.wait_for_unit("multi-user.target") + + # The module should install timer and service unit files for all users + machine.succeed("test -f /etc/systemd/user/nix-cache-login.timer") + machine.succeed("test -f /etc/systemd/user/nix-cache-login.service") + + # wantedBy = ["timers.target"] should create this symlink + machine.succeed( + "test -L /etc/systemd/user/timers.target.wants/nix-cache-login.timer" + ) + + # Service unit should reference the correct subcommand + unit = machine.succeed("cat /etc/systemd/user/nix-cache-login.service") + assert "nix-cache-login refresh" in unit, ( + f"ExecStart not found in service unit:\n{unit}" + ) + ''; +} diff --git a/package.nix b/package.nix index c5344f1..e85a434 100644 --- a/package.nix +++ b/package.nix @@ -6,7 +6,7 @@ buildGoModule { pname = "nix-cache-login"; - version = "0.1.0"; + version = "0.1.2"; src = ./.; # src = fetchgit { # url = "https://guardianproject.dev/ops/nix-cache-login.git";