Ssh config as (agenix) secret

I am in the process of switching to NixOS, and now I want to add my ssh config (actually there are only aliases) to config. I use home-manager to configure ssh and agenix to store my secrets. As VPSes are under DDOS protection and their IPs are kind of secret, I would like to not store those IPs in public repository.

So the main problem is that path to file with secret contains env variable $XDG_RUNTIME_DIR, and ssh doesn’t evaluate env variables in Include option. Solutions I tried:

programs.ssh.includes = [config.age.secrets.ssh-config.path];

Result: ssh: Could not resolve hostname EDITED: Name or service not known

# somewhere read that env variables are written in format `${NAME}` in ssh config
programs.ssh.includes = [(builtins.replaceStrings ["$XDG_RUNTIME_DIR"] ["\${XDG_RUNTIME_DIR}"] config.age.secrets.ssh-config.path)];

Result: ssh: Could not resolve hostname EDITED: Name or service not known

programs.ssh.includes = [".ssh/my_config"];
home.file.".ssh/my_config".source = config.age.secrets.ssh-config.path;

Result: error: A definition for option 'home-manager.users.perchun.home.file.".ssh/real_config".source' is not of type 'path'. Definition values: "$XDG_RUNTIME_DIR/agenix/ssh-config"

programs.ssh.includes = [".ssh/my_config"];
home.activation = lib.hm.dag.entryAfter ["writeBoundary"] ''
  run ln -s $VERBOSE_ARG "${config.age.secrets.ssh-config.path}" $HOME/.ssh/my_config
'';

Result (a bug in home-manager?; if I will pass writeBoundary as a string, it will yell at empty list instead of ["writeBoundary"]):


       error: A definition for option `home-manager.users.perchun.home.activation.after.data' is not of type `string'. Definition values:
       - In `/nix/store/ipsimfwkwmi5y53iqlw6hrzqlwxnhkxf-source/home-manager/ssh.nix':
           [
             "writeBoundary"
           ]
programs.ssh.extraConfig = 
      builtins.readFile 
      (builtins.replaceStrings ["$XDG_RUNTIME_DIR"] [(builtins.getEnv "XDG_RUNTIME_DIR")] config.age.secrets.ssh-config.path);

Result (builtins.getEnv "XDG_RUNTIME_DIR" returns an empty string): error: access to absolute path '/agenix/ssh-config' is forbidden in pure eval mode (use '--impure' to override)


So my question is: how can I include my ssh config as a secret, or is there any other way to configure aliases for my VPSes without storing IPs publicly?

My inputs from flake.nix:

  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs/nixos-23.11";
    nixpkgs-unstable.url = "github:nixos/nixpkgs/nixos-unstable";

    home-manager = {
      url = "github:nix-community/home-manager/release-23.11";
      inputs.nixpkgs.follows = "nixpkgs";
    };
    agenix = {
      url = "github:ryantm/agenix";
      inputs.nixpkgs.follows = "nixpkgs";
    };
  };

My flake.lock for exact versions (if you need those): { "nodes": { "agenix": { "inputs": { "darwin": "darwin", - Pastebin.com

I use home-manager to configure ssh and agenix to store my secrets. As VPSes are under DDOS protection and their IPs are kind of secret, I would like to not store those IPs in public repository.

For simple access needs I would suggest using a wireguard network – hand-rolled or something like [head|tail]scale rather than having SSH listen on a public IP even on a non-standard port.

Result: ssh: Could not resolve hostname EDITED: Name or service not known

If you are providing hosts like so:

Host domain.tld
  User <whatever>
  # other options

Can you actually resolve domain.tld just from command line using host domain.tld? If you need to hard-code the IP, you could use HostName directive:

Host domain.tld
  User <whatever>
  HostName 1.2.3.4
  # other options

So my question is: how can I include my ssh config as a secret, or is there any other way to configure aliases for my VPSes without storing IPs publicly?

There’s also a pattern of having two flakes: a public and a private one. Private one exports NixOS or home manager modules that are imported in the public one. There’s an example and some context in this post.

For simple access needs I would suggest using a wireguard network – hand-rolled or something like [head|tail]scale rather than having SSH listen on a public IP even on a non-standard port.

I don’t host something popular and my main server (where everything is hosted) is under zerotier, because it doesn’t have public IP. Other servers are not-important to me at all, though giving them alias is convenient (like servers of my clients from freelance, that kind of stuff). So I agree it would be a better security practice, but it is more hassle that I would want to do with every machine I want to get an alias for.

If you are providing hosts like so:

It was unclear in my original message, so here is more context for that error:

$ # on my current, not nixos, machine
$ cat ~/.ssh/config
...
Host somename
    HostName 123.123.123.123
    User root
...
$ ssh somename
# connects to the machine

But on NixOS it outputs

$ ssh somename
# 30 seconds later...
Could not resolve hostname somename: Name or service not known

So it tries to resolve somename with DNS or something like that, not finding solution in the config.

There’s also a pattern of having two flakes: a public and a private one. Private one exports NixOS or home manager modules that are imported in the public one. There’s an example and some context in this post.

Oh, that’s so cool, I will definitely consider this option if I will have trouble with agenix for one day once more.

I did some experiments with the home-manager and agenix. Looks like ssh does not expand all environment variables by default (see man 5 ssh_config), so it was not treating $XDG_... dirs as an absolute path instead trying to find the file inside ~/.ssh.

Invoking ssh with -vvv will show what exactly it’s trying to include.

One approach here is to do specify the agenix path and mount it inside the user’s home. In programs.ssh.includes you can strip the prefix and make the import relative. Or just include a wildcard of all files in a dir like ~/.ssh/includes/*.

{
  inputs = {
    nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
    home-manager.url = "github:nix-community/home-manager";
    home-manager.inputs.nixpkgs.follows = "nixpkgs";
    agenix.url = "github:ryantm/agenix";
  };

  outputs =
    inputs@{ self, nixpkgs, ... }:
    let
      system = "x86_64-linux";
      pkgs = import nixpkgs { inherit system; };
      inherit (pkgs) lib;
    in
    {

      checks.${system}.test = pkgs.testers.runNixOSTest {
        name = "foo";
        nodes.machine1 =
          { config, pkgs, ... }:
          {
            services.getty.autologinUser = "alice";
            imports = [ inputs.home-manager.nixosModules.home-manager ];
            users.users.alice = {
              isNormalUser = true;
              password = "hunter2";
            };
            home-manager.users.alice =
              { config, ... }: # config is home-manager's config, not the OS one
              {
                imports = [ inputs.agenix.homeManagerModules.default ];
                home.stateVersion = "24.05";
                home.file.".ssh/id_ed25519".source = ./id_ed25519; # Don't do this to a real key, it's world-readable in store. For test VM it's OK.
                home.file.".ssh/id_ed25519.pub".source = ./id_ed25519.pub;
                programs.ssh = {
                  enable = true;
                  includes = [
                    (lib.removePrefix ".ssh/" config.age.secrets.ssh-config.path) # This makes the include relative from ssh's perspective
                  ];
                };
                age.secrets.ssh-config.file = ./ssh-config.age;
                age.secrets.ssh-config.path = ".ssh/includes/ssh-config-agenix";
              };
          };
        testScript = "start_all()";
      };
    };
}

And the output from the user’s shell (runnable as nix run .\#checks.x86_64-linux.test.driverInteractive):

$ cat .ssh/config
Include includes/ssh-config-agenix


Host *
  ForwardAgent no
  AddKeysToAgent no
  Compression no
  ServerAliveInterval 0
  ServerAliveCountMax 3
  HashKnownHosts no
  UserKnownHostsFile ~/.ssh/known_hosts
  ControlMaster no
  ControlPath ~/.ssh/master-%r@%n:%p
  ControlPersist no

  
$ cat .ssh/includes/*
Host alias
    HostName 1.2.3.4
$ ssh -vvv alias
machine1 # bash-5.2$ bash-5.2$ bash-5.2$ bash-5.2$ bash-5.2$ bash-5.2$ bash-5.2$ bash-5.2$ bash-5.2$ OpenSSH_9.6p1, OpenSSL 3
.0.13 30 Jan 2024
machine1 # debug1: Reading configuration data /home/alice/.ssh/config
machine1 # debug3: /home/alice/.ssh/config line 1: Including file /home/alice/.ssh/includes/ssh-config-agenix depth 0
machine1 # debug1: Reading configuration data /home/alice/.ssh/includes/ssh-config-agenix
machine1 # debug1: /home/alice/.ssh/includes/ssh-config-agenix line 1: Applying options for alias
machine1 # debug1: /home/alice/.ssh/config line 4: Applying options for *
machine1 # debug1: Reading configuration data /etc/ssh/ssh_config
machine1 # debug1: /etc/ssh/ssh_config line 5: Applying options for *
machine1 # debug2: resolve_canonicalize: hostname 1.2.3.4 is address
machine1 # debug3: expanded UserKnownHostsFile '~/.ssh/known_hosts' -> '/home/alice/.ssh/known_hosts'
machine1 # debug1: Control socket "/home/alice/.ssh/master-alice@alias:22" does not exist
machine1 # debug3: channel_clear_timeouts: clearing
machine1 # debug3: ssh_connect_direct: entering
machine1 # debug1: Connecting to 1.2.3.4 [1.2.3.4] port 22.
machine1 # debug3: set_sock_tos: set socket 3 IP_TOS 0x48

Thanks! Didn’t realize I can overwrite path to secret