nixpkgs/nixos/modules/services/misc/portunus.nix
2024-06-14 02:41:42 -04:00

307 lines
9.7 KiB
Nix

{ config, lib, pkgs, ... }:
with lib;
let
cfg = config.services.portunus;
in
{
options.services.portunus = {
enable = mkEnableOption "Portunus, a self-contained user/group management and authentication service for LDAP";
domain = mkOption {
type = types.str;
example = "sso.example.com";
description = "Subdomain which gets reverse proxied to Portunus webserver.";
};
port = mkOption {
type = types.port;
default = 8080;
description = ''
Port where the Portunus webserver should listen on.
This must be put behind a TLS-capable reverse proxy because Portunus only listens on localhost.
'';
};
package = mkPackageOption pkgs "portunus" { };
seedPath = mkOption {
type = types.nullOr types.path;
default = null;
description = ''
Path to a portunus seed file in json format.
See <https://github.com/majewsky/portunus#seeding-users-and-groups-from-static-configuration> for available options.
'';
};
seedSettings = lib.mkOption {
type = with lib.types; nullOr (attrsOf (listOf (attrsOf anything)));
default = null;
description = ''
Seed settings for users and groups.
See upstream for format <https://github.com/majewsky/portunus#seeding-users-and-groups-from-static-configuration>
'';
};
stateDir = mkOption {
type = types.path;
default = "/var/lib/portunus";
description = "Path where Portunus stores its state.";
};
user = mkOption {
type = types.str;
default = "portunus";
description = "User account under which Portunus runs its webserver.";
};
group = mkOption {
type = types.str;
default = "portunus";
description = "Group account under which Portunus runs its webserver.";
};
dex = {
enable = mkEnableOption ''
Dex ldap connector.
To activate dex, first a search user must be created in the Portunus web ui
and then the password must to be set as the `DEX_SEARCH_USER_PASSWORD` environment variable
in the [](#opt-services.dex.environmentFile) setting
'';
oidcClients = mkOption {
type = types.listOf (types.submodule {
options = {
callbackURL = mkOption {
type = types.str;
description = "URL where the OIDC client should redirect";
};
id = mkOption {
type = types.str;
description = "ID of the OIDC client";
};
};
});
default = [ ];
example = [
{
callbackURL = "https://example.com/client/oidc/callback";
id = "service";
}
];
description = ''
List of OIDC clients.
The OIDC secret must be set as the `DEX_CLIENT_''${id}` environment variable
in the [](#opt-services.dex.environmentFile) setting.
::: {.note}
Make sure the id only contains characters that are allowed in an environment variable name, e.g. no -.
:::
'';
};
port = mkOption {
type = types.port;
default = 5556;
description = "Port where dex should listen on.";
};
};
ldap = {
package = mkOption {
type = types.package;
default = pkgs.openldap;
defaultText = lib.literalExpression "pkgs.openldap.override { libxcrypt = pkgs.libxcrypt-legacy; }";
description = "The OpenLDAP package to use.";
};
searchUserName = mkOption {
type = types.str;
default = "";
example = "admin";
description = ''
The login name of the search user.
This user account must be configured in Portunus either manually or via seeding.
'';
};
suffix = mkOption {
type = types.str;
example = "dc=example,dc=org";
description = ''
The DN of the topmost entry in your LDAP directory.
Please refer to the Portunus documentation for more information on how this impacts the structure of the LDAP directory.
'';
};
tls = mkOption {
type = types.bool;
default = false;
description = ''
Whether to enable LDAPS protocol.
This also adds two entries to the `/etc/hosts` file to point [](#opt-services.portunus.domain) to localhost,
so that CLIs and programs can use ldaps protocol and verify the certificate without opening the firewall port for the protocol.
This requires a TLS certificate for [](#opt-services.portunus.domain) to be configured via [](#opt-security.acme.certs).
'';
};
user = mkOption {
type = types.str;
default = "openldap";
description = "User account under which Portunus runs its LDAP server.";
};
group = mkOption {
type = types.str;
default = "openldap";
description = "Group account under which Portunus runs its LDAP server.";
};
};
};
config = mkIf cfg.enable {
assertions = [
{
assertion = cfg.dex.enable -> cfg.ldap.searchUserName != "";
message = "services.portunus.dex.enable requires services.portunus.ldap.searchUserName to be set.";
}
];
# add ldapsearch(1) etc. to interactive shells
environment.systemPackages = [ cfg.ldap.package ];
# allow connecting via ldaps /w certificate without opening ports
networking.hosts = mkIf cfg.ldap.tls {
"::1" = [ cfg.domain ];
"127.0.0.1" = [ cfg.domain ];
};
services = {
dex = mkIf cfg.dex.enable {
enable = true;
settings = {
issuer = "https://${cfg.domain}/dex";
web.http = "127.0.0.1:${toString cfg.dex.port}";
storage = {
type = "sqlite3";
config.file = "/var/lib/dex/dex.db";
};
enablePasswordDB = false;
connectors = [{
type = "ldap";
id = "ldap";
name = "LDAP";
config = {
host = "${cfg.domain}:636";
bindDN = "uid=${cfg.ldap.searchUserName},ou=users,${cfg.ldap.suffix}";
bindPW = "$DEX_SEARCH_USER_PASSWORD";
userSearch = {
baseDN = "ou=users,${cfg.ldap.suffix}";
filter = "(objectclass=person)";
username = "uid";
idAttr = "uid";
emailAttr = "mail";
nameAttr = "cn";
preferredUsernameAttr = "uid";
};
groupSearch = {
baseDN = "ou=groups,${cfg.ldap.suffix}";
filter = "(objectclass=groupOfNames)";
nameAttr = "cn";
userMatchers = [{ userAttr = "DN"; groupAttr = "member"; }];
};
};
}];
staticClients = forEach cfg.dex.oidcClients (client: {
inherit (client) id;
redirectURIs = [ client.callbackURL ];
name = "OIDC for ${client.id}";
secretEnv = "DEX_CLIENT_${client.id}";
});
};
};
portunus.seedPath = lib.mkIf (cfg.seedSettings != null) (pkgs.writeText "seed.json" (builtins.toJSON cfg.seedSettings));
};
systemd.services = {
dex = mkIf cfg.dex.enable {
serviceConfig = {
# `dex.service` is super locked down out of the box, but we need some
# place to write the SQLite database. This creates $STATE_DIRECTORY below
# /var/lib/private because DynamicUser=true, but it gets symlinked into
# /var/lib/dex inside the unit
StateDirectory = "dex";
};
};
portunus = {
description = "Self-contained authentication service";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
ExecStart = "${cfg.package}/bin/portunus-orchestrator";
Restart = "on-failure";
};
environment = {
PORTUNUS_LDAP_SUFFIX = cfg.ldap.suffix;
PORTUNUS_SERVER_BINARY = "${cfg.package}/bin/portunus-server";
PORTUNUS_SERVER_GROUP = cfg.group;
PORTUNUS_SERVER_USER = cfg.user;
PORTUNUS_SERVER_HTTP_LISTEN = "127.0.0.1:${toString cfg.port}";
PORTUNUS_SERVER_STATE_DIR = cfg.stateDir;
PORTUNUS_SLAPD_BINARY = "${cfg.ldap.package}/libexec/slapd";
PORTUNUS_SLAPD_GROUP = cfg.ldap.group;
PORTUNUS_SLAPD_USER = cfg.ldap.user;
PORTUNUS_SLAPD_SCHEMA_DIR = "${cfg.ldap.package}/etc/schema";
} // (optionalAttrs (cfg.seedPath != null) ({
PORTUNUS_SEED_PATH = cfg.seedPath;
})) // (optionalAttrs cfg.ldap.tls (
let
acmeDirectory = config.security.acme.certs."${cfg.domain}".directory;
in
{
PORTUNUS_SERVER_HTTP_SECURE = "true";
PORTUNUS_SLAPD_TLS_CA_CERTIFICATE = "/etc/ssl/certs/ca-certificates.crt";
PORTUNUS_SLAPD_TLS_CERTIFICATE = "${acmeDirectory}/cert.pem";
PORTUNUS_SLAPD_TLS_DOMAIN_NAME = cfg.domain;
PORTUNUS_SLAPD_TLS_PRIVATE_KEY = "${acmeDirectory}/key.pem";
}));
};
};
users.users = mkMerge [
(mkIf (cfg.ldap.user == "openldap") {
openldap = {
group = cfg.ldap.group;
isSystemUser = true;
};
})
(mkIf (cfg.user == "portunus") {
portunus = {
group = cfg.group;
isSystemUser = true;
};
})
];
users.groups = mkMerge [
(mkIf (cfg.ldap.user == "openldap") {
openldap = { };
})
(mkIf (cfg.user == "portunus") {
portunus = { };
})
];
};
meta.maintainers = [ maintainers.majewsky ] ++ teams.c3d2.members;
}