Skip to content

Facts from other hosts

Some configuration of a host is about the other hosts: SSH knownHosts, WireGuard peers, Syncthing devices, backup targets. The usual answers:

  • A hand-written list of hosts in a shared file. It goes out of date when you add a host.
  • self.nixosConfigurations.<other>.config through specialArgs. When two hosts read derived values from each other, the evaluation can recurse without end.
  • The collector aspect of the dendritic design guide: each host feature adds its own device to a shared NixOS module. It works, but every host gets every contribution, its own included.

In yen a host’s public facts are fields of its instance. Here are three hosts and their facts:

modules/fleet.nix
# Each host carries its public facts as fields. Every module can read every host: they are data, not NixOS configs.
{
yen,
lib,
inputs,
...
}:
let
str = yen.lib.str;
in
{
yen.kinds.fleet = {
spawns.host = _: yen.host;
sinks.out.reduce = yen.lib.reduce.merge;
};
yen.kinds.host = {
fields = {
# (1)
sshKey = str;
wgKey = str;
wgIp = str;
};
sinks.nixos.out =
{ host, nixos, ... }:
{
nixosConfigurations.${host.name} = inputs.nixpkgs.lib.nixosSystem { modules = nixos; };
};
};
yen.fleet.main = { };
yen.flake = { out, ... }: out;
yen.host = {
# (2)
nas = {
sshKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINas";
wgKey = "nas0wgPublicKey0000000000000000000000000000=";
wgIp = "10.9.0.1";
};
desk = {
sshKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIDesk";
wgKey = "desk0wgPublicKey000000000000000000000000000=";
wgIp = "10.9.0.2";
};
laptop = {
sshKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILap";
wgKey = "laptop0wgPublicKey0000000000000000000000000=";
wgIp = "10.9.0.3";
};
};
yen.aspects.machine = {
at = yen.kinds.host;
nixos =
{ host, ... }:
{
networking.hostName = host.name;
boot.loader.grub.device = "nodev";
fileSystems."/" = {
device = "/dev/sda1";
fsType = "ext4";
};
nixpkgs.hostPlatform = "x86_64-linux";
system.stateVersion = "25.05";
};
};
}

(1) declares the facts as fields of the host kind. (2) gives their values for each host.

An aspect reads the other hosts through a tree request, a form of the cycle:

modules/mesh.nix
# known_hosts and WireGuard peers: every other host, from its fields. No hosts list to keep in sync.
# Other hosts' data comes through a tree request, so each read is an edge `stage` can see.
{ yen, lib, ... }:
let
all.instances = yen.kinds.host; # (1) a tree request: every host, with its data
othersOf = tree: scope: lib.filter (h: !yen.lib.same h scope) (tree.select all); # every host but this one
in
{
yen.aspects.mesh = {
# (2)
at = yen.kinds.host;
cycle =
{ tree, scope, ... }:
{
tree = [ all ]; # (3)
nixos = [
{
programs.ssh.knownHosts = lib.listToAttrs (
map (h: {
name = h.name;
value = {
hostNames = [ h.name ];
publicKey = h.sshKey;
};
}) (othersOf tree scope)
);
networking.wireguard.interfaces.wg0 = {
ips = [ "${scope.wgIp}/24" ];
privateKeyFile = "/var/lib/wireguard/private.key";
peers = map (h: {
publicKey = h.wgKey;
allowedIPs = [ "${h.wgIp}/32" ];
}) (othersOf tree scope);
};
}
];
};
};
}

(1) is the tree request: every host, with its data. (2) is the mesh aspect. At (3) it asks the tree for that request. The request is data, so nothing evaluates a NixOS system to read it, and stage sees the read.

A Syncthing device id exists only on hosts that run Syncthing. Each of them gives its id to the syncthing-device sink of the fleet. A host has no such sink, so the id goes up to the fleet.

modules/syncthing.nix
# A fact that only some hosts have: each Syncthing host gives its device id. The ids go up to the fleet;
# every Syncthing host reads them back and adds the others as devices.
{ yen, lib, ... }:
{
yen.kinds.fleet.sinks.syncthing-device.reduce = yen.lib.reduce.elements; # (1) each id with the host that gave it
yen.aspects.syncthing = {
at = [
yen.host.nas
yen.host.desk
];
nixos =
{ host, syncthing-device, ... }: # (2)
{
services.syncthing = {
enable = true;
settings.devices = lib.listToAttrs (
map (e: lib.nameValuePair e.from.name { inherit (e.value) id; }) (
lib.filter (e: e.from.name != host.name) syncthing-device
)
);
};
};
};
# the ids, in each host's own file in a real fleet
yen.aspects.nas-id = {
# (3)
at = yen.host.nas;
syncthing-device.id = "NAS1111-1111111-1111111-1111111-1111111-1111111-1111111-1111111";
};
yen.aspects.desk-id = {
at = yen.host.desk;
syncthing-device.id = "DESK222-2222222-2222222-2222222-2222222-2222222-2222222-2222222";
};
}

(1) is the sink of the fleet. Its reduce is elements, which keeps who gave each id. (2) is a Syncthing host: it reads the sink as an arg, and leaves out its own id. (3) is one host that gives its id.

ContributeCommunitySponsor