diff --git a/topology/nixos/extractors/services.nix b/topology/nixos/extractors/services.nix new file mode 100644 index 0000000..8a1af3e --- /dev/null +++ b/topology/nixos/extractors/services.nix @@ -0,0 +1,18 @@ +{ + config, + lib, + pkgs, + ... +}: let + inherit + (lib) + mkIf + ; +in { + topology.self.services = { + vaultwarden = mkIf config.services.vaultwarden.enable { + name = "Vaultwarden"; + icon = "${pkgs.vaultwarden.webvault}/share/vaultwarden/vault/images/safari-pinned-tab.svg"; + }; + }; +} diff --git a/topology/nixos/extractors/systemd.nix b/topology/nixos/extractors/systemd.nix index 2c63c08..13a69ef 100644 --- a/topology/nixos/extractors/systemd.nix +++ b/topology/nixos/extractors/systemd.nix @@ -1,2 +1,47 @@ { + config, + lib, + ... +}: let + inherit + (lib) + concatLists + flip + mkDefault + mkIf + mkMerge + optional + ; +in { + #config = mkIf config.systemd.network.enable { + # topology.interfaces = mkMerge ( + # # Create interfaces based on systemd.network.netdevs + # concatLists ( + # flip mapAttrsToList config.systemd.network.netdevs ( + # _unit: netdev: + # optional (netdev ? netdevConfig.Name) { + # ${netdev.netdevConfig.Name} = { + # physical = mkDefault false; + # }; + # } + # ) + # ) + # # Add interface configuration based on systemd.network.networks + # #++ concatLists ( + # # flip mapAttrsToList config.systemd.network.networks ( + # # _unit: network: + # # optional (network ? matchConfig.Name) { + # # ${network.networkConfig.Name} = { + # # }; + # # } + # # ) + # #) + # ); + + # #self.interfaces = { + # #}; + # #networks.somenet = { + # # connections = []; + # #}; + #}; } diff --git a/topology/nixos/extractors/wireguard.nix b/topology/nixos/extractors/wireguard.nix index 2c63c08..9002412 100644 --- a/topology/nixos/extractors/wireguard.nix +++ b/topology/nixos/extractors/wireguard.nix @@ -1,2 +1,84 @@ { + config, + lib, + inputs ? {}, + ... +}: let + inherit + (lib) + flip + mapAttrsToList + mkDefault + mkIf + mkMerge + filter + optionals + ; + + headOrNull = xs: + if xs == [] + then null + else builtins.head xs; + + networkId = wgName: "wireguard-${wgName}"; +in { + config = mkIf (config ? wireguard) { + # Create networks (this will be duplicated by each node, + # but it doesn't matter and will be merged anyway) + topology.networks = mkMerge ( + flip mapAttrsToList config.wireguard ( + wgName: _: let + inherit (lib.wireguard inputs wgName) networkCidrs; + in { + ${networkId wgName} = { + name = mkDefault "Wireguard network '${wgName}'"; + cidrv4 = headOrNull (filter lib.net.ip.isv4 networkCidrs); + cidrv6 = headOrNull (filter lib.net.ip.isv6 networkCidrs); + }; + } + ) + ); + + # Assign network and physical connections to related interfaces + topology.self.interfaces = mkMerge ( + flip mapAttrsToList config.wireguard ( + wgName: wgCfg: let + inherit + (lib.wireguard inputs wgName) + participatingClientNodes + participatingServerNodes + wgCfgOf + ; + + isServer = wgCfg.server.host != null; + filterSelf = filter (x: x != config.node.name); + + # All nodes that use our node as the via into the wireguard network + ourClientNodes = + optionals isServer + (filter (n: (wgCfgOf n).client.via == config.node.name) participatingClientNodes); + + # The list of peers that are "physically" connected in the wireguard network, + # meaning they communicate directly with each other. + connectedPeers = + if isServer + then + # Other servers in the same network + filterSelf participatingServerNodes + # Our clients + ++ ourClientNodes + else [wgCfg.client.via]; + in { + ${wgCfg.linkName} = { + network = networkId wgName; + virtual = true; + physicalConnections = flip map connectedPeers (peer: { + node = peer; + interface = (wgCfgOf peer).linkName; + }); + }; + } + ) + ); + }; } diff --git a/topology/nixos/module.nix b/topology/nixos/module.nix index 3e7cb1a..8885302 100644 --- a/topology/nixos/module.nix +++ b/topology/nixos/module.nix @@ -17,6 +17,9 @@ in { # Allow simple alias to set/get attributes of this node (mkAliasOptionModule ["topology" "self"] ["topology" "nodes" config.topology.id]) ] + # Include extractors + ++ map (x: ./extractors/${x}) (attrNames (builtins.readDir ./extractors)) + # Include common topology options ++ flip map (attrNames (builtins.readDir ../options)) (x: import ../options/${x} ( module: @@ -48,49 +51,4 @@ in { # Ensure a node exists for this host nodes.${config.topology.id} = {}; }; - - #config.topology = mkMerge [ - # { - # ################### TODO user config! ################# - # id = config.node.name; - # ################### END user config ################# - - # guests = - # flip mapAttrsToList (config.microvm.vms or {}) - # (_: vmCfg: vmCfg.config.config.topology.id); - # # TODO: container - - # disks = - # flip mapAttrs (config.disko.devices.disk or {}) - # (_: _: {}); - # # TODO: zfs pools from disko / fileSystems - # # TODO: microvm shares - # # TODO: container shares - # # TODO: OCI containers shares - - # interfaces = let - # isNetwork = netDef: (netDef.matchConfig != {}) && (netDef.address != [] || netDef.DHCP != null); - # macsByName = mapAttrs' (flip nameValuePair) (config.networking.renameInterfacesByMac or {}); - # netNameFor = netName: netDef: - # netDef.matchConfig.Name - # or ( - # if netDef ? matchConfig.MACAddress && macsByName ? ${netDef.matchConfig.MACAddress} - # then macsByName.${netDef.matchConfig.MACAddress} - # else lib.trace "Could not derive network name for systemd network ${netName} on host ${config.node.name}, using unit name as fallback." netName - # ); - # netMACFor = netDef: netDef.matchConfig.MACAddress or null; - # networks = filterAttrs (_: isNetwork) (config.systemd.network.networks or {}); - # in - # flip mapAttrs' networks (netName: netDef: - # nameValuePair (netNameFor netName netDef) { - # mac = netMACFor netDef; - # addresses = - # if netDef.address != [] - # then netDef.address - # else ["DHCP"]; - # }); - - # # TODO: for each nftable zone show open ports - # } - #]; } diff --git a/topology/options/disks.nix b/topology/options/disks.nix index 18d7226..f5bef10 100644 --- a/topology/options/disks.nix +++ b/topology/options/disks.nix @@ -17,8 +17,8 @@ in default = {}; type = types.attrsOf (types.submodule (submod: { options = { - name = mkOption { - description = "The name of this disk"; + id = mkOption { + description = "The id of this disk"; default = submod.config._module.args.name; readOnly = true; type = types.str; diff --git a/topology/options/firewall.nix b/topology/options/firewall.nix index 67e4f4a..177b297 100644 --- a/topology/options/firewall.nix +++ b/topology/options/firewall.nix @@ -18,8 +18,8 @@ in default = {}; type = types.attrsOf (types.submodule (submod: { options = { - name = mkOption { - description = "The name of this firewall rule"; + id = mkOption { + description = "The id of this firewall rule"; type = types.str; readOnly = true; default = submod.config._module.args.name; diff --git a/topology/options/interfaces.nix b/topology/options/interfaces.nix index 2f2e3f1..192d5e5 100644 --- a/topology/options/interfaces.nix +++ b/topology/options/interfaces.nix @@ -5,6 +5,9 @@ f: { }: let inherit (lib) + attrValues + flatten + flip mkOption types ; @@ -18,36 +21,83 @@ in default = {}; type = types.attrsOf (types.submodule (submod: { options = { - name = mkOption { - description = "The name of this interface"; + id = mkOption { + description = "The id of this interface"; type = types.str; readOnly = true; default = submod.config._module.args.name; }; + virtual = mkOption { + description = "Whether this is a virtual interface."; + type = types.bool; + }; + mac = mkOption { description = "The MAC address of this interface, if known."; default = null; type = types.nullOr types.str; }; - addresses = mkOption { - description = "The configured address(es), or a descriptive string (like DHCP)."; - type = types.listOf types.str; - }; + #addresses = mkOption { + # description = "The configured address(es), or a descriptive string (like DHCP)."; + # type = types.listOf types.str; + #}; + + #gateway = mkOption { + # description = "The configured gateway, if any"; + # type = types.nullOr types.str; + # default = null; + #}; network = mkOption { - description = '' - The global name of the attached/spanned network. - If this is given, this interface can be shown in the network graph. - ''; + description = "The id of the network to which this interface belongs, if any."; default = null; type = types.nullOr types.str; }; + + physicalConnections = mkOption { + description = "A list of other node interfaces to which this node is physically connected."; + default = []; + type = types.listOf (types.submodule { + options = { + node = mkOption { + description = "The other node id."; + type = types.str; + }; + + interface = mkOption { + description = "The other node's interface id."; + type = types.str; + }; + }; + }); + }; }; })); }; }; }); }; + + config = { + assertions = flatten (flip map (attrValues config.nodes) ( + node: + flip map (attrValues node.interfaces) ( + interface: + [ + { + assertion = config.networks ? ${interface.network}; + message = "topology: nodes.${node.id}.interfaces.${interface.id} refers to an unknown network '${interface.network}'"; + } + ] + ++ flip map interface.physicalConnections ( + physicalConnection: { + assertion = config.nodes ? ${physicalConnection.node} && config.nodes.${physicalConnection.node}.interfaces ? ${physicalConnection.interface}; + message = "topology: nodes.${node.id}.interfaces.${interface.id}.physicalConnections refers to an unknown node/interface nodes.${physicalConnection.node}.interfaces.${physicalConnection.interface}"; + } + ) + ) + )); + }; } diff --git a/topology/options/networks.nix b/topology/options/networks.nix new file mode 100644 index 0000000..6ca255b --- /dev/null +++ b/topology/options/networks.nix @@ -0,0 +1,57 @@ +f: { + lib, + config, + ... +}: let + inherit + (lib) + mkOption + types + ; +in + f { + options.networks = mkOption { + default = {}; + description = '' + ''; + type = types.attrsOf (types.submodule (networkSubmod: { + options = { + id = mkOption { + description = "The id of this network"; + default = networkSubmod.config._module.args.name; + readOnly = true; + type = types.str; + }; + + name = mkOption { + description = "The name of this network"; + type = types.str; + default = "Unnamed network '${networkSubmod.config.id}'"; + }; + + color = mkOption { + description = "The color of this network"; + default = "random"; + type = types.either (types.strMatching "^#[0-9a-f]{6}$") (types.enum ["random"]); + }; + + cidrv4 = mkOption { + description = "The CIDRv4 address space of this network or null if it doesn't use ipv4"; + default = null; + #type = types.nullOr types.net.cidrv4; + type = types.nullOr types.str; + }; + + cidrv6 = mkOption { + description = "The CIDRv6 address space of this network or null if it doesn't use ipv6"; + default = null; + #type = types.nullOr types.net.cidrv6; + type = types.nullOr types.str; + }; + + # FIXME: vlan ids + # FIXME: nat to [other networks] (happening on node XY) + }; + })); + }; + } diff --git a/topology/options/nodes.nix b/topology/options/nodes.nix index 5a61f35..73dac08 100644 --- a/topology/options/nodes.nix +++ b/topology/options/nodes.nix @@ -5,6 +5,7 @@ f: { }: let inherit (lib) + literalExpression mkOption types ; @@ -16,17 +17,28 @@ in ''; type = types.attrsOf (types.submodule (nodeSubmod: { options = { - name = mkOption { - description = "The name of this node"; + id = mkOption { + description = "The id of this node"; default = nodeSubmod.config._module.args.name; readOnly = true; type = types.str; }; + name = mkOption { + description = "The name of this node"; + type = types.str; + default = nodeSubmod.config.id; + defaultText = literalExpression ''""''; + }; + + # FIXME: TODO emoji / icon + # FIXME: TODO hardware description "Odroid H3" + # FIXME: TODO hardware image + + # FIXME: TODO are these good types? how about nixos vs router vs ... type = mkOption { description = "TODO"; - default = "normal"; - type = types.enum ["normal" "microvm" "nixos-container"]; + type = types.enum ["nixos" "microvm" "nixos-container"]; }; parent = mkOption { diff --git a/topology/options/services.nix b/topology/options/services.nix new file mode 100644 index 0000000..fa8a421 --- /dev/null +++ b/topology/options/services.nix @@ -0,0 +1,56 @@ +f: { + lib, + config, + ... +}: let + inherit + (lib) + mkOption + types + ; +in + f { + options.nodes = mkOption { + type = types.attrsOf (types.submodule { + options = { + services = mkOption { + description = "TODO"; + default = {}; + type = types.attrsOf (types.submodule (submod: { + options = { + id = mkOption { + description = "The id of this service"; + type = types.str; + readOnly = true; + default = submod.config._module.args.name; + }; + + name = mkOption { + description = "The name of this service"; + type = types.str; + }; + + icon = mkOption { + description = "The icon for this service"; + type = types.nullOr types.path; + default = null; + }; + + url = mkOption { + description = "The URL under which the service is reachable, if any."; + type = types.nullOr types.str; + default = null; + }; + + listenAddresses = mkOption { + description = "The addresses on which this service listens."; + type = types.nullOr types.str; + default = null; + }; + }; + })); + }; + }; + }); + }; + } diff --git a/topology/topology/default.nix b/topology/topology/default.nix index fe14c20..cc2a0da 100644 --- a/topology/topology/default.nix +++ b/topology/topology/default.nix @@ -7,6 +7,8 @@ (lib) attrNames concatLists + concatStringsSep + filter filterAttrs flip getAttrFromPath @@ -54,6 +56,35 @@ in { readOnly = true; defaultText = literalExpression ''config.renderers.${config.renderer}.output''; }; + + assertions = mkOption { + internal = true; + default = []; + example = [ + { + assertion = false; + message = "you can't enable this for that reason"; + } + ]; + description = lib.mdDoc '' + This option allows modules to express conditions that must + hold for the evaluation of the topology configuration to + succeed, along with associated error messages for the user. + ''; + type = types.listOf (types.submodule { + options = { + assertion = mkOption { + description = "The thing to assert."; + type = types.bool; + }; + + message = mkOption { + description = "The error message."; + type = types.str; + }; + }; + }); + }; }; config = let @@ -68,10 +99,14 @@ in { )) ); in { - output = - mkIf (config.renderer != null) - (mkDefault config.renderers.${config.renderer}.output); + output = let + failedAssertions = map (x: x.message) (filter (x: !x.assertion) config.assertions); + in + if failedAssertions != [] + then throw "\nFailed assertions:\n${concatStringsSep "\n" (map (x: "- ${x}") failedAssertions)}" + else mkIf (config.renderer != null) (mkDefault config.renderers.${config.renderer}.output); nodes = aggregate ["nodes"]; + networks = aggregate ["networks"]; }; } diff --git a/topology/topology/renderers/d2/network.nix b/topology/topology/renderers/d2/network.nix index 9b85742..23f2902 100644 --- a/topology/topology/renderers/d2/network.nix +++ b/topology/topology/renderers/d2/network.nix @@ -6,25 +6,53 @@ }: let inherit (lib) + attrValues concatLines - mapAttrsToList ; - toD2 = _nodeName: node: '' - ${node.name}: |md - # ${node.name} + #toD2 = _nodeName: node: '' + # ${node.id}: |md + # # ${node.id} - ## Disks: - ${concatLines (mapAttrsToList (_: v: "- ${v.name}") node.disks)} + # ## Disks: + # ${concatLines (mapAttrsToList (_: v: "- ${v.id}") node.disks)} - ## Interfaces: - ${concatLines (mapAttrsToList (_: v: "- ${v.name}, mac ${toString v.mac}, addrs ${toString v.addresses}, network ${toString v.network}") node.interfaces)} + # ## Interfaces: + # ${concatLines (mapAttrsToList (_: v: "- ${v.id}, mac ${toString v.mac}, addrs ${toString v.addresses}, network ${toString v.network}") node.interfaces)} - ## Firewall Zones: - ${concatLines (mapAttrsToList (_: v: "- ${v.name}, mac ${toString v.mac}, addrs ${toString v.addresses}, network ${toString v.network}") node.firewallRules)} + # ## Firewall Zones: + # ${concatLines (mapAttrsToList (_: v: "- ${v.id}, mac ${toString v.mac}, addrs ${toString v.addresses}, network ${toString v.network}") node.firewallRules)} + + # ## Services: + # ${concatLines (mapAttrsToList (_: v: "- ${v.id}, name ${toString v.name}, icon ${toString v.icon}, url ${toString v.url}") node.services)} + # | + #''; + + netToD2 = net: '' + ${net.id}: |md + # ${net.name} + ${net.cidrv4} + ${net.cidrv6} | ''; + + nodeInterfaceToD2 = node: interface: '' + ${node.id}.${interface.id}: |md + ## ${interface.id} + | + + ${node.id}.${interface.id} -> ${interface.network} + ''; + + nodeToD2 = node: '' + ${node.id}: |md + # ${node.name} + | + + ${concatLines (map (nodeInterfaceToD2 node) (attrValues node.interfaces))} + ''; in pkgs.writeText "network.d2" '' - ${concatLines (mapAttrsToList toD2 config.nodes)} + ${concatLines (map netToD2 (attrValues config.networks))} + ${concatLines (map nodeToD2 (attrValues config.nodes))} ''