Automaticly assign port number to service

In my configuration, I want to change the fixed publishTo values to a generated value. So that:

  services.gosk = {
    enable = true;
    processors = rec {
      connectAis = {
        command = "connect";
        publishTo = 6001;
        configFile = config.sops.templates.connectAis.path;
      };
      mapAis = {
        command = "map";
        publishTo = 6002;
        subscribeTo = [ connectAis.publishTo ];
        configFile = config.sops.templates.connectAis.path;
      };
    };
  };

Becomes something like this:

  services.gosk = {
    enable = true;
    processors = rec {
      connectAis = {
        command = "connect";
        publishTo = portNumberFunction;
        configFile = config.sops.templates.connectAis.path;
      };
      mapAis = {
        command = "map";
        publishTo = portNumberFunction;
        subscribeTo = [ connectAis.publishTo ];
        configFile = config.sops.templates.connectAis.path;
      };
    };
  };

Where portNumberFunction returns a port number that changes each call. This not a pure function, so it should take an argument, I guess. Can I somehow use the position in the attribute set to achieve this?

1 Like

I have a thing for this that hashes a name of your choosing and truncates the numerical value of the hash so it fits into a port range (e.g. 40000-50000). It’s a random-looking but deterministic way of generating port numbers. I use this extensively to hook up backend services to my load balancers.

Thanks! That looks like what I need. I will try to figure out how to incorporate that in my code. I’m pretty new to Nix and still struggling with the syntax.

I think I’m getting there, but still I’m missing some things. My complete config for the service is:

{ config, lib, pkgs, utils, ... }:

with lib;

let
  cfg = config.services.gosk;

  # Escape as required by: https://www.freedesktop.org/software/systemd/man/systemd.unit.html
  escapeUnitName = name:
    lib.concatMapStrings (s: if lib.isList s then "-" else s)
    (builtins.split "[^a-zA-Z0-9_.\\-]+" name);
  portHash = flip pipe [
    (builtins.hashString "md5")
    (builtins.substring 0 7)
    (hash: (fromTOML "v=0x${hash}").v)
    (flip mod cfg.reservedPorts.amount)
    (add cfg.reservedPorts.start)
  ];
in {
  options.services.gosk = {
    enable = mkEnableOption "Go Signal K service";

    package = mkPackageOption pkgs "gosk" { };

    reservedPorts = {
      amount = mkOption {
        type = with types; int;
        default = 10000;
        description = "Amount of ports to reserve at most.";
      };
      start = mkOption {
        type = with types; port;
        default = 30000;
        description = "Starting point for reserved ports.";
      };
    };

    processors = mkOption {
      type = types.attrsOf (types.submodule {
        options = {
          args = mkOption {
            type = types.separatedString " ";
            default = "";
            example = "write database mapped";
            description = "Arguments to use for this gosk service.";
          };
          subscribeTo = mkOption {
            type = with types; listOf port;
            default = [ ];
            example = [ 6000 6001 ];
            description = "Ports to subscribe to.";
          };
          publish = mkOption {
            type = with types; bool;
            default = true;
            example = false;
            description = "Determines if the services publishes its output.";
          };
          publishTo = mkOption {
            type = with types; port;
            default = portHash "__I want to use the name and args values here__";
            example = 6000;
            description =
              "Port to publish to, if not defined a 'random' port will be chosen.";
          };
          configFile = mkOption {
            type = with types; nullOr path;
            default = null;
            example = ./gosk.yaml;
            description =
              "Configuration file in YAML format for this gosk service.";
          };
          after = mkOption {
            type = types.listOf utils.systemdUtils.lib.unitNameType;
            default = [ ];
            example = [ "postgresql" ];
            description = "Systemd services to wait for";
          };
          extraArgs = mkOption {
            type = with types; listOf str;
            default = [ ];
            example = ''
              [ "--pmport" "127.0.0.1:9203" ]
            '';
            description = "Extra arguments for the gosk processor.";
          };
        };
      });
      default = { };
      example = literalExpression ''
        {
          mapAis = {
            args = "map";
            subscribeTo = [ connectAis.publishTo ];
            configFile = ./gosk.yaml;
          };
        }
      '';
      description = "gosk service to run.";
    };
  };

  config = mkIf cfg.enable {
    systemd.services = mapAttrs' (name: c:
      nameValuePair "gosk-${escapeUnitName name}" (mkMerge [{
        inherit (c) after;
        description = "gosk ${name}";
        wantedBy = [ "multi-user.target" ];

        serviceConfig = {
          ExecStart = toString ([ "${pkgs.gosk}/bin/gosk" ]
            ++ lib.optional (c.args != "") c.args ++ [
              (concatMapStringsSep " "
                (port: "-s tcp://127.0.0.1:${toString port}") c.subscribeTo)
            ] ++ lib.optional (isInt c.publishTo)
            "-p tcp://127.0.0.1:${toString c.publishTo}"
            ++ lib.optional (c.configFile != null)
            "--config \${CREDENTIALS_DIRECTORY}/gosk.yaml" ++ c.extraArgs);
          Restart = "always";
          LoadCredential =
            if c.configFile != null then "gosk.yaml:${c.configFile}" else "";
          DynamicUser = "yes";
        };
      }])) cfg.processors;
    # sops.templates = mapAttrs' (name: c:
    # ) cfg.processors;
  };

  meta.maintainers = with maintainers; [ munnik ];
}

And to enable the service I have:

{ config, ... }:
let signalKSelf = "vessels.urn:mrn:imo:mmsi:244770688";
in {
  sops.secrets = {
    mosquittoPassword = { };
    postgresqlGoskPassword = { };
  };

  services.gosk = {
    enable = true;
    processors = rec {
      connectAis = {
        args = "connect";
        publishTo = 7000;
        configFile = config.sops.templates.connectAis.path;
      };
      connectAmpero = {
        args = "connect";
        configFile = config.sops.templates.connectAmpero.path;
      };
      mapAis = {
        args = "map";
        subscribeTo = [ connectAis.publishTo ];
        configFile = config.sops.templates.connectAis.path;
      };
    };
  };
  sops.templates = {
    connectAis = {
      content = ''
        ---
        name: "AIS"
        protocol: "nmea0183"
        url: "file:///dev/ttyUSBCom0"
        baudRate: 38400
        dataBits: 8
        stopBits: 1
        parity: "N"
      '';
    };
    connectAmpero = {
      content = ''
        ---
        name: "Ampero modules"
        protocol: "modbus"
        url: "rtu:///dev/ttyUSBCom2"
        baudRate: 19200
        dataBits: 8
        stopBits: 1
        parity: "N"
        registerGroups:
          - slave: 1 # read counter 1 and 2
            functionCode: 4
            address: 52
            numberOfCoilsOrRegisters: 4
            pollingInterval: 5s
      '';
    };
    mapAis = {
      content = ''
        ---
        context: "${signalKSelf}" # if the data itself doesn't provide a context then this context is used
        protocol: "nmea0183"
      '';
    };
  };
}

Two questions remaining:

  1. If I don’t specify publishTo in the connectAis processor, I can’t use it in the subscribeTo of mapAis. The error I get is:
 error: attribute 'publishTo' missing
       at /nix/store/3s2s159zm791l7msk967wy1sqakaw8f1-source/hosts/node0001/services/gosk/default.nix:23:25:
           22|         args = "map";
           23|         subscribeTo = [ connectAis.publishTo ];
             |                         ^
           24|         configFile = config.sops.templates.connectAis.path;

It seems you can’t access default values? For the connectAmpero processor, the port number is successfully generated. Output of systemctl status gosk-connectAmpero.service:

Process: 12822 ExecStart=/nix/store/fq0fgkp5f7nqh55gvkwh0xq1l2ilnsfd-gosk-0.1.103/bin/gosk connect -p tcp://127.0.0.1:34636 --config ${CREDENTIALS_DIRECTORY}/gosk.yaml (code=exited, status=1/FAILURE)
  1. How can I use the args and name of the processor as arguments in the portHash function, see the text __I want to use the name and args values here__ in the config above. I think I need to do this in the section config = mkIf cfg.enable { } but I’m not sure how.

Any other tips and ideas regarding this config are more than welcome. I still have to learn a lot.

I resolved all the issues with the following config, this also include numbering of the network ports.

{ config, lib, pkgs, utils, ... }:
let
  # Escape as required by: https://www.freedesktop.org/software/systemd/man/systemd.unit.html
  escapeUnitName = name:
    "gosk-" + lib.concatMapStrings (s: if lib.isList s then "-" else s)
    (builtins.split "[^a-zA-Z0-9_.\\-]+" name);
  processorPort = processor:
    toString (config.services.gosk.reservedPorts.start
      + (lib.lists.findFirstIndex (p: p == processor) 0
        (lib.mapAttrsToList (_: value: value)
          config.services.gosk.processors)));
  processor = {
    options = {
      args = lib.mkOption {
        type = with lib.types; separatedString " ";
        default = "";
        example = "write database mapped";
        description = "Arguments to use for this gosk service.";
      };
      extraArgs = lib.mkOption {
        type = with lib.types; listOf str;
        default = [ ];
        example = ''
          [ "--pmport" "127.0.0.1:9203" ]
        '';
        description = "Extra arguments for the gosk processor.";
      };
      config = lib.mkOption {
        type = with lib.types; attrs;
        default = { };
        example = {
          username = lib.literalExpression "mosquittoUsername;";
          password =
            lib.literalExpression "config.sops.placeholder.mosquittoPassword;";
          url = "mqtt://10.24.0.1:1883";
          interval = "10s";
          buffer_size = 1000;
          example_required_file = ./extraFiles/test.bin;
        };
        description = "Configuration for this gosk service.";
      };
      subscribeTo = lib.mkOption {
        type = with lib.types; listOf (submodule processor);
        default = [ ];
        example = lib.literalExpression "[ connectAis ]";
        description = "Processors to subscribe to.";
      };
      after = lib.mkOption {
        type = with lib.types; listOf utils.systemdUtils.lib.unitNameType;
        default = [ ];
        example = [ "postgresql" ];
        description = "Systemd services to wait for";
      };
    };
  };
in {
  options.services.gosk = {
    enable = lib.mkEnableOption "Go Signal K service";

    package = lib.mkPackageOption pkgs "gosk" { };

    reservedPorts = {
      amount = lib.mkOption {
        type = with lib.types; int;
        default = 10000;
        description = "Amount of ports to reserve at most.";
      };
      start = lib.mkOption {
        type = with lib.types; port;
        default = 30000;
        description = "Starting point for reserved ports.";
      };
    };

    processors = lib.mkOption {
      type = with lib.types; attrsOf (submodule processor);
      default = { };
      example = lib.literalExpression ''
        {
          mapAis = {
            args = "map";
            subscribeTo = [ connectAis ];
            configFile = ./gosk.yaml;
          };
        }
      '';
      description = "gosk service to run.";
    };
  };

  config = lib.mkIf config.services.gosk.enable {
    systemd.services = lib.mapAttrs' (name: p:
      let
        hasSubscribers = lib.elem p (lib.lists.flatten
          (lib.mapAttrsToList (_: p: p.subscribeTo)
            config.services.gosk.processors));
      in lib.nameValuePair (escapeUnitName name) {
        inherit (p) after;
        description = "gosk ${name}";
        wantedBy = [ "multi-user.target" ];

        serviceConfig = {
          ExecStart = toString ([ "${pkgs.gosk}/bin/gosk" ]
            ++ lib.optional (p.args != "") p.args ++ p.extraArgs ++ [
              (lib.concatMapStringsSep " "
                (processor: "-s tcp://127.0.0.1:${processorPort processor}")
                p.subscribeTo)
            ] ++ lib.optional hasSubscribers
            ((processor: "-p tcp://127.0.0.1:${processorPort processor}") p)
            ++ lib.optional (p.config != { })
            "--config \${CREDENTIALS_DIRECTORY}/config.json");
          Restart = "always";
          LoadCredential = if p.config != { } then
            "config.json:${config.sops.templates."${escapeUnitName name}".path}"
          else
            "";
          DynamicUser = "yes";
        };
      }) config.services.gosk.processors;
    sops.templates = lib.mapAttrs' (name: _:
      lib.nameValuePair (escapeUnitName name) {
        content =
          builtins.toJSON config.services.gosk.processors."${name}".config;
      }) config.services.gosk.processors;
  };

  meta.maintainers = with lib.maintainers; [ munnik ];
}