diff --git a/flake.nix b/flake.nix index 7af109cc..9b4719b0 100644 --- a/flake.nix +++ b/flake.nix @@ -301,6 +301,7 @@ ocf-cosmic-applets = ocf-cosmic-applets.packages.${final.stdenv.hostPlatform.system}.default; ocf-cosmic-greeter = final.callPackage ./pkgs/ocf-cosmic-greeter.nix { }; ocf-hplip = final.callPackage ./pkgs/ocf-hplip.nix { }; + ldap-git-backup = final.callPackage ./pkgs/ldap-git-backup.nix { }; ocf-niks3-push = final.callPackage ./pkgs/ocf-niks3-push { niks3 = niks3.packages.${final.stdenv.hostPlatform.system}.default; }; diff --git a/hosts/server/eel.nix b/hosts/server/eel.nix new file mode 100644 index 00000000..80570782 --- /dev/null +++ b/hosts/server/eel.nix @@ -0,0 +1,31 @@ +{ + pkgs, + lib, + config, + ... +}: + +{ + imports = [ ../../hardware/virtualized.nix ]; + + networking.hostName = "eel"; + + ocf.motd.description = '' + LDAP and Kerberos server; crucial to the rest of our infrastucture working. + ''; + + ocf.network = { + enable = true; + lastOctet = 98; + }; + + ocf.kerberosKdc.enable = true; + ocf.ldapServer.enable = true; + + ocf.acme.extraCerts = [ + "ldap0.ocf.berkeley.edu" + "kdc.ocf.berkeley.edu" + ]; + + system.stateVersion = "25.11"; +} diff --git a/modules/auth.nix b/modules/auth/default.nix similarity index 90% rename from modules/auth.nix rename to modules/auth/default.nix index 6c572100..41692143 100644 --- a/modules/auth.nix +++ b/modules/auth/default.nix @@ -7,11 +7,11 @@ let cfg = config.ocf.auth; - keytabSecretPath = ../secrets/master-keyed/keytabs + "/${config.networking.hostName}.age"; + keytabSecretPath = ../../secrets/master-keyed/keytabs + "/${config.networking.hostName}.age"; hasKeytab = builtins.pathExists keytabSecretPath; # sort for regular *.pub files - hostKeyDir = ../secrets/host-keys; + hostKeyDir = ../../secrets/host-keys; hostKeyFiles = lib.filterAttrs ( filename: filetype: lib.hasSuffix ".pub" filename && filetype == "regular" ) (builtins.readDir hostKeyDir); @@ -48,7 +48,7 @@ in }; config = lib.mkIf cfg.enable { - age.secrets.root-password-hash.rekeyFile = ../secrets/master-keyed/root-password-hash.age; + age.secrets.root-password-hash.rekeyFile = ../../secrets/master-keyed/root-password-hash.age; # Per-host keytab for GSSAPI SSH authentication # Only configured if the host has a keytab in secrets/master-keyed/keytabs/.age @@ -66,7 +66,7 @@ in ldap = { enable = true; - server = "ldaps://ldap.ocf.berkeley.edu"; + server = "ldaps://ldap0.ocf.berkeley.edu ldaps://ldap.ocf.berkeley.edu"; base = "dc=OCF,dc=Berkeley,dc=EDU"; daemon.enable = true; extraConfig = '' @@ -91,7 +91,7 @@ in }; environment.etc."ldap/ldap.conf".text = '' - URI ldaps://ldap.ocf.berkeley.edu + URI ldaps://ldap0.ocf.berkeley.edu ldaps://ldap.ocf.berkeley.edu BASE dc=ocf,dc=berkeley,dc=edu TLS_REQCERT hard TLS_CACERT /etc/ssl/certs/ca-certificates.crt @@ -158,8 +158,11 @@ in settings = { realms."OCF.BERKELEY.EDU" = { - admin_server = "kerberos.ocf.berkeley.edu"; - kdc = [ "kerberos.ocf.berkeley.edu" ]; + admin_server = "kdc.ocf.berkeley.edu"; + kdc = [ + "kdc.ocf.berkeley.edu" + "kerberos.ocf.berkeley.edu" + ]; }; domain_realm = { "ocf.berkeley.edu" = "OCF.BERKELEY.EDU"; diff --git a/modules/kerberos-kdc/check-pass-strength.py b/modules/kerberos-kdc/check-pass-strength.py new file mode 100644 index 00000000..c7d68f59 --- /dev/null +++ b/modules/kerberos-kdc/check-pass-strength.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +"""Enforce OCF account password complexity. + +Designed to be used as an external password check program by Heimdal. Uses +ocflib to validate password strength with the same requirements as all other +password changing tools. + +Details on interface Heimdal uses: +http://www.h5l.org/manual/HEAD/info/heimdal/Password-changing.html + +Example usage: +$ echo -e "principal: ckuehl@OCF.BERKELEY.EDU\nnew-password: hello" | ./check-pass-strength + +(ported directly from our old puppet module: https://github.com/ocf/puppet/blob/master/modules/ocf_kerberos/files/check-pass-strength) +""" +import sys + +import ocflib.account.utils as utils +import ocflib.account.validators as validators + + +if __name__ == '__main__': + data = {} + while True: + line = sys.stdin.readline().rstrip('\n') + if line == 'end' or not line: + break + else: + line = line.split(':') + assert len(line) == 2, 'Could not parse input: ' + str(line) + data[line[0]] = line[1][1:] + try: + username = utils.extract_username_from_principal(data['principal']) + password = data['new-password'] + except (KeyError, ValueError): + print('Did not receive principal or password from input', file=sys.stderr) + sys.exit(1) + else: + try: + validators.validate_password(username, password) + except ValueError as e: + print(e, file=sys.stderr) + else: + print('APPROVED') diff --git a/modules/kerberos-kdc/default.nix b/modules/kerberos-kdc/default.nix new file mode 100644 index 00000000..857af7ab --- /dev/null +++ b/modules/kerberos-kdc/default.nix @@ -0,0 +1,135 @@ +{ + lib, + config, + pkgs, + ... +}: + +let + cfg = config.ocf.kerberosKdc; + + # check-pass-strength wrapped with a Python environment that has ocflib. + checkPassStrength = pkgs.writeShellScript "check-pass-strength" '' + exec ${pkgs.python312.withPackages (ps: [ ps.ocflib ])}/bin/python3 \ + ${./check-pass-strength.py} "$@" + ''; +in +{ + options.ocf.kerberosKdc = { + enable = lib.mkEnableOption "OCF Kerberos KDC server (Heimdal)"; + }; + + # The KDC database lives in /var/lib/heimdal/ and must be initialized manually: + # kadmin -l init OCF.BERKELEY.EDU + # For migration between hosts, dump with: kadmin -l dump + # and restore with: kadmin -l load + + config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = config.security.krb5.package.passthru.implementation or null == "heimdal"; + message = "ocf.kerberosKdc requires Heimdal; security.krb5.package is not a Heimdal build."; + } + ]; + + services.kerberos_server = { + enable = true; + settings = { + kdc.extra-addresses = "127.0.0.2"; + kdc.enable-fast = false; + + realms."OCF.BERKELEY.EDU" = { + acl = [ + # Staff /admin principals have full KDC access + { + principal = "*/admin"; + access = [ "all" ]; + } + # Staff /root principals can change any principal's password + { + principal = "*/root"; + access = [ "cpw" ]; + target = "*@OCF.BERKELEY.EDU"; + } + # create/admin is used by account creation tooling (ocflib) + { + principal = "create/admin"; + access = [ + "add" + "get" + "cpw" + ]; + target = "*@OCF.BERKELEY.EDU"; + } + ]; + }; + + # a wrapper for ocflib's `validate_password`. more complex than doing + # it natively, but good to standardize password requirements across + # ocf/utils and ocf/ocfweb. heimdal's external password checking like + # this might not be easily possible in MIT...? consider if migrating + # off of heimdal kerb. + + password_quality = { + policies = "external-check"; + external_program = "${checkPassStrength}"; + }; + }; + }; + + # KDC must start after slapd so SASL/GSSAPI is available for KDC → LDAP lookups + systemd.services.kdc.after = [ "openldap.service" ]; + + environment.systemPackages = [ pkgs.heimdal ]; + + networking.firewall = { + allowedTCPPorts = [ + 88 # kerberos + 749 # kadmin + ]; + allowedUDPPorts = [ + 88 # kerberos + 464 # kpasswd + ]; + }; + + systemd.tmpfiles.rules = [ + "d /var/backups/kerberos 0700 root root -" + ]; + + # unlike LDAP, not uploaded to github (for now?) + systemd.services.kerberos-git-backup = { + description = "Kerberos KDC git backup"; + after = [ "kdc.service" ]; + requires = [ "kdc.service" ]; + path = [ + pkgs.heimdal + pkgs.git + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + UMask = "0077"; + }; + script = '' + dir=/var/backups/kerberos + [ -d "$dir/.git" ] || git -C "$dir" init -q + kadmin -l dump --decrypt "$dir/kerberos.dump" + git -C "$dir" add kerberos.dump + git -C "$dir" commit -q -m 'kerberos-git-backup' --allow-empty kerberos.dump + git -C "$dir" gc --auto --quiet + ''; + }; + + systemd.timers.kerberos-git-backup = { + description = "Run Kerberos KDC git backup daily"; + wantedBy = [ "timers.target" ]; + timerConfig = { + # Run before rsnapshot so the backup server gets a fresh daily snapshot + OnCalendar = "*-*-* 01:00:00"; + Persistent = true; + }; + }; + + }; +} diff --git a/modules/ldap-server/default.nix b/modules/ldap-server/default.nix new file mode 100644 index 00000000..ddd91a14 --- /dev/null +++ b/modules/ldap-server/default.nix @@ -0,0 +1,239 @@ +{ + lib, + config, + pkgs, + ... +}: + +let + cfg = config.ocf.ldapServer; + fqdn = "${config.networking.hostName}.ocf.berkeley.edu"; + ldapKeytabPath = ../../secrets/master-keyed/ldap-keytab.age; + hasLdapKeytab = builtins.pathExists ldapKeytabPath; + certDir = "/var/lib/acme/${fqdn}"; + + ldapLint = pkgs.writeShellScriptBin "ldap-lint" '' + exec ${ + pkgs.python312.withPackages (ps: [ + ps.ocflib + ps.dnspython + ]) + }/bin/python3 \ + ${./ldap-lint.py} "$@" + ''; +in +{ + options.ocf.ldapServer = { + enable = lib.mkEnableOption "OCF OpenLDAP server"; + }; + + config = lib.mkIf cfg.enable { + # Keytab for the ldap/@OCF.BERKELEY.EDU service principal. + # The path is exposed to slapd via the KRB5_KTNAME environment variable. + # Only configured if the keytab secret exists (not available until KDC is initialized). + age.secrets.ldap-keytab = lib.mkIf hasLdapKeytab { + rekeyFile = ldapKeytabPath; + path = "/etc/openldap/ldap.keytab"; + owner = "openldap"; + group = "openldap"; + mode = "0400"; + }; + + # slapd process runs as openldap user and needs to read acme certs, but acme certs are owned by acme user and acme group by default. + security.acme.certs.${fqdn}.group = lib.mkDefault "openldap"; + + # Cyrus SASL configuration for slapd GSSAPI authentication. + # The keytab path comes from KRB5_KTNAME in the service environment, not here. + environment.etc."sasl2/slapd.conf" = { + text = '' + mech_list: GSSAPI + pwcheck_method: saslauthd + ''; + mode = "0444"; + }; + + # TODO: try removing, not sure if this is necessary, but it was present in the ocf_ldap puppet module... not moving yet for this migration from puppet to nix + services.saslauthd = { + enable = true; + mechanism = "kerberos5"; + }; + + services.openldap = { + enable = true; + # ldaps only — no unencrypted ldap://; ldapi for local admin access + urlList = [ + "ldaps:///" + "ldapi:///" + ]; + settings = { + attrs = { + olcLogLevel = [ "0" ]; + + # TLS — cert issued for ${fqdn} with ldap.ocf.berkeley.edu as a SAN + olcTLSCACertificateFile = "/etc/ssl/certs/ca-certificates.crt"; + olcTLSCertificateFile = "${certDir}/fullchain.pem"; + olcTLSCertificateKeyFile = "${certDir}/key.pem"; + olcTLSVerifyClient = "never"; + # OpenSSL cipher string (NixOS OpenLDAP uses OpenSSL, not GnuTLS) + olcTLSCipherSuite = "HIGH:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!3DES:!MD5:!PSK"; + + # SASL/GSSAPI realm — map Kerberos principals to LDAP DNs + olcSaslRealm = "OCF.BERKELEY.EDU"; + olcAuthzRegexp = [ + # Regular users → their ou=People entry + "{0}uid=([^,/]+),cn=OCF.BERKELEY.EDU,cn=GSSAPI,cn=auth uid=$1,ou=People,dc=OCF,dc=Berkeley,dc=EDU" + # Hosts → their ou=Hosts entry + "{1}uid=host/([^,/]+)\\.ocf\\.berkeley\\.edu,cn=OCF.BERKELEY.EDU,cn=GSSAPI,cn=auth cn=$1,ou=Hosts,dc=OCF,dc=Berkeley,dc=EDU" + ]; + }; + + # TODO: once all debian hosts deployed to with puppet are deprecated, remove puppet.schema.ldif + children = { + "cn=schema" = { + includes = [ + "${pkgs.openldap}/etc/schema/core.ldif" + "${pkgs.openldap}/etc/schema/cosine.ldif" + "${pkgs.openldap}/etc/schema/nis.ldif" + ./puppet.schema.ldif + ./ocf.schema.ldif + ]; + }; + + "olcDatabase={0}config" = { + attrs = { + objectClass = [ "olcDatabaseConfig" ]; + olcDatabase = "{0}config"; + # Only local root (via SASL EXTERNAL) may modify cn=config + olcAccess = [ + "{0}to * by dn.exact=gidNumber=0+uidNumber=0,cn=peercred,cn=external,cn=auth manage by * break" + ]; + }; + }; + + "olcDatabase={1}mdb" = { + attrs = { + objectClass = [ + "olcDatabaseConfig" + "olcMdbConfig" + ]; + olcDatabase = "{1}mdb"; + olcDbDirectory = "/var/lib/openldap/data"; + olcSuffix = "dc=OCF,dc=Berkeley,dc=EDU"; + # No rootDN password — all admin access is via SASL/GSSAPI + olcSizeLimit = "-1"; + olcDbMaxSize = "2147483648"; # 2 GiB + olcDbIndex = [ + "objectClass eq" + "uid,uidNumber eq" + "memberUid,uniqueMember eq" + "cn eq,sub" + "calnetUid,oslGid,callinkOid eq,pres" + ]; + + # Note: the puppet slapd used ocf/ldap-overlay to synthesize + # ocfEmail dynamically from uid (for some reason?). since + # migrating from puppet to nix, we now store ocfEmail directly on + # user entries and populate by ocflib on account + # creation/modification (see ocfweb). + + # ocfEmail is only currently used in the rt and waddles (ai + # chatbot) repos anyway, no real reason for it to exist. may + # delete soon anyway. + + olcAccess = [ + # Root DSE is readable by everyone + "{0}to dn.base=\"\" by * read" + # Only /admin (with GSSAPI) can write userPassword; owner can read; anonymous can auth + "{1}to dn.subtree=\"ou=People,dc=OCF,dc=Berkeley,dc=EDU\" attrs=userPassword by sasl_ssf=56 dn.regex=\"^uid=[^,/]+/admin,cn=OCF.BERKELEY.EDU,cn=GSSAPI,cn=auth$\" write by sasl_ssf=56 self read by sasl_ssf=56 anonymous auth by * none" + # Hosts can update their own puppet environment; /admin can write; users can read over SSL + "{2}to dn.subtree=\"ou=Hosts,dc=OCF,dc=Berkeley,dc=EDU\" attrs=environment by sasl_ssf=56 dn.regex=\"^uid=[^,/]+/admin,cn=OCF.BERKELEY.EDU,cn=GSSAPI,cn=auth$\" write by sasl_ssf=56 self write by sasl_ssf=56 users read by tls_ssf=256 anonymous read" + # Sorried users cannot change their own shell + "{3}to dn.subtree=\"ou=People,dc=OCF,dc=Berkeley,dc=EDU\" filter=(loginShell=/opt/share/utils/bin/sorried) attrs=loginShell by sasl_ssf=56 dn.regex=\"^uid=[^,/]+/admin,cn=OCF.BERKELEY.EDU,cn=GSSAPI,cn=auth$\" write by sasl_ssf=56 users read by tls_ssf=256 anonymous read" + # Non-sorried users can change their own shell + + "{4}to dn.subtree=\"ou=People,dc=OCF,dc=Berkeley,dc=EDU\" attrs=loginShell by sasl_ssf=56 dn.regex=\"^uid=[^,/]+/admin,cn=OCF.BERKELEY.EDU,cn=GSSAPI,cn=auth$\" write by sasl_ssf=56 self write by sasl_ssf=56 users read by tls_ssf=256 anonymous read" + # mail: /admin and /root can read; smtp service can read; owner can write + "{5}to dn.subtree=\"ou=People,dc=OCF,dc=Berkeley,dc=EDU\" attrs=mail by sasl_ssf=56 dn.regex=\"^uid=[^,/]+/admin,cn=OCF.BERKELEY.EDU,cn=GSSAPI,cn=auth$\" write by sasl_ssf=56 dn.regex=\"^uid=[^,/]+/root,cn=OCF.BERKELEY.EDU,cn=GSSAPI,cn=auth$\" read by sasl_ssf=56 dn=\"uid=smtp/anthrax.ocf.berkeley.edu,cn=OCF.BERKELEY.EDU,cn=GSSAPI,cn=auth\" read by sasl_ssf=56 self write by * none" + # Everything else: /admin can write; authenticated users can read over GSSAPI; anonymous can read over TLS + "{6}to * by sasl_ssf=56 dn.regex=\"^uid=[^,/]+/admin,cn=OCF.BERKELEY.EDU,cn=GSSAPI,cn=auth$\" write by sasl_ssf=56 users read by tls_ssf=256 anonymous read" + ]; + }; + }; + }; + }; + }; + + systemd.services.openldap = { + serviceConfig = { + # Point slapd at the GSSAPI keytab + Environment = "KRB5_KTNAME=/etc/openldap/ldap.keytab"; + }; + }; + + environment.systemPackages = [ ldapLint ]; + + networking.firewall.allowedTCPPorts = [ 636 ]; + + systemd.tmpfiles.rules = [ + "d /var/lib/openldap/data 0700 openldap openldap -" + "d /etc/openldap 0750 openldap openldap -" + "d /var/backups/ldap 0700 root root -" + ]; + + # SSH deploy key for pushing the LDAP backup to github.com:ocf/ldap + age.secrets.ldap-github-deploy-key = { + rekeyFile = ../../secrets/master-keyed/eel/ldap-github-deploy-key.age; + path = "/root/.ssh/id_rsa_ldap_backup"; + owner = "root"; + group = "root"; + mode = "0600"; + }; + + # Pin GitHub's host key so the backup push doesn't need to prompt + programs.ssh.knownHosts."github.com" = { + publicKey = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk="; + }; + + systemd.services.ldap-git-backup = { + description = "LDAP git backup"; + after = [ "openldap.service" ]; + requires = [ "openldap.service" ]; + path = [ + pkgs.ldap-git-backup + pkgs.openldap + pkgs.git + pkgs.openssh_gssapi + ]; + serviceConfig = { + Type = "oneshot"; + User = "root"; + UMask = "0077"; + # git gc exits 7 when there is nothing to collect; treat as success + SuccessExitStatus = "7"; + }; + environment = { + GIT_SSH_COMMAND = "ssh -i ${config.age.secrets.ldap-github-deploy-key.path}"; + }; + script = '' + git -C /var/backups/ldap config user.name "root" + git -C /var/backups/ldap config user.email "root@${config.networking.hostName}.ocf.berkeley.edu" + ldap_exit=0 + ldap-git-backup --backup-dir /var/backups/ldap \ + --ldif-cmd "${pkgs.openldap}/bin/slapcat -F /etc/openldap/slapd.d" || ldap_exit=$? + # exit code 7 means git gc had nothing to do — not a real error + [ "$ldap_exit" -eq 0 ] || [ "$ldap_exit" -eq 7 ] || exit "$ldap_exit" + git -C /var/backups/ldap push -q git@github.com:ocf/ldap master + ''; + }; + + systemd.timers.ldap-git-backup = { + description = "Run LDAP git backup daily"; + wantedBy = [ "timers.target" ]; + timerConfig = { + # Run before rsnapshot so the backup server gets a fresh daily snapshot + OnCalendar = "*-*-* 01:00:00"; + Persistent = true; + }; + }; + }; +} diff --git a/modules/ldap-server/ldap-lint.py b/modules/ldap-server/ldap-lint.py new file mode 100755 index 00000000..0b85e8ec --- /dev/null +++ b/modules/ldap-server/ldap-lint.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Check the sanity of our LDAP database. + +Currently checks the Hosts OU for: + * Duplicate IP addresses + * Duplicate MAC addresses + * Missing IP address + * Existence of MAC address (should be present only on type=desktop) + * Invalid or missing type + * Host not in DNS or mismatched IP + * Unrecognized puppetVar (often typos) + * Reverse DNS for IP exists and matches hostname + * IP is not an OCF address + * IPv6 does not match IPv4 + * Does not have an IPv6 address + +It also does some checks around users and kerberos principals: + * All users with a /root principal should also have a /admin principal + * All ocfroot members should be in ocfstaff too + * All ocfroot members should have a /root and /admin principal +""" +import subprocess +import sys +from ipaddress import ip_address +from operator import itemgetter + +import dns +from ocflib.infra.hosts import HOST_TYPES_WITH_IPV6 +from ocflib.infra.ldap import ldap_connection +from ocflib.infra.ldap import OCF_LDAP_GROUP +from ocflib.infra.ldap import OCF_LDAP_HOSTS +from ocflib.infra.net import ipv4_to_ipv6 +from ocflib.infra.net import ipv6_to_ipv4 +from ocflib.infra.net import is_ocf_ip +from ocflib.infra.net import OCF_SUBNET_V6_COMPAT +from ocflib.misc.shell import bold +from ocflib.misc.shell import red + + +RECOGNIZED_TYPES = frozenset({ + 'desktop', + 'dhcp', + 'ipmi', + 'printer', + 'server', + 'staffvm', + 'switch', + 'vip', + 'wifi', +}) + +# inclusive +STAFFVM_RANGE = (ip_address('169.229.226.200'), ip_address('169.229.226.252')) + + +def lookup_dns(host, rtype='A'): + """Return string representation of first record, or None.""" + try: + return str(dns.resolver.query(host, rtype)[0]) or None + except dns.resolver.NXDOMAIN: + return None + + +def check_hosts(c, complain): + seen_macs = {} + seen_ips = {} + + c.search( + OCF_LDAP_HOSTS, + '(cn=*)', + attributes=['cn', 'type', 'macAddress', 'ipHostNumber', 'ip6HostNumber', 'puppetVar', 'puppetClass'], + ) + for attrs in map(itemgetter('attributes'), c.response): + cn = attrs['cn'][0] + type_ = attrs['type'] + + if type_ not in RECOGNIZED_TYPES: + complain(cn, 'has unknown type ' + type_) + + if attrs['macAddress']: + mac_addr = attrs['macAddress'][0].lower() + + if type_ != 'desktop': + complain(cn, 'has a MAC address but not a desktop') + + if mac_addr in seen_macs: + complain(cn, 'has same MAC address as ' + seen_macs[mac_addr]) + else: + seen_macs[mac_addr] = cn + elif type_ == 'desktop': + complain(cn, 'has no MAC address but is a desktop') + + ip = ip_address(attrs['ipHostNumber'][0]) + in_staffvm_range = STAFFVM_RANGE[0] <= ip <= STAFFVM_RANGE[1] + if type_ == 'staffvm' and not in_staffvm_range and not cn.startswith('hozer-'): + complain(cn, 'is a staff VM, but not in staffvm IP range') + elif type_ != 'staffvm' and in_staffvm_range: + complain(cn, 'is in staffvm IP range, but not a staffvm') + if not is_ocf_ip(ip): + complain(cn, 'has the IP address {}, which is not an OCF IP'.format(ip)) + ip6s = [ip_address(ip6) for ip6 in attrs['ip6HostNumber']] + for ip6 in ip6s: + if not is_ocf_ip(ip6): + complain(cn, 'has the IP address {}, which is not an OCF IP'.format(ip6)) + if ip6 in OCF_SUBNET_V6_COMPAT and ipv6_to_ipv4(ip6) != ip: + complain(cn, 'has the IPv6 address {}, which does not match its IPv4 address'.format(ip6)) + if (len(ip6s) > 0 or type_ in HOST_TYPES_WITH_IPV6) and ipv4_to_ipv6(ip) not in ip6s: + complain(cn, 'does not have an ip6HostNumber attribute matching its ipHostNumber') + + ip = attrs['ipHostNumber'][0] + dns_ip = lookup_dns(cn + '.ocf.berkeley.edu') + + if not dns_ip: + complain(cn, 'has no A record in DNS') + else: + if ip in seen_ips: + complain(cn, 'has same IP address as ' + seen_ips[ip]) + else: + seen_ips[ip] = cn + + if dns_ip != ip: + complain(cn, 'ldap ip {} doesn\'t match dns ip {}'.format(ip, dns_ip)) + + ptr = lookup_dns(dns.reversename.from_address(ip), rtype='PTR') + + if ptr: + if ptr.lower() != cn + '.ocf.berkeley.edu.': + complain(cn, 'bad reverse DNS for {}: {}'.format(ip, ptr)) + else: + complain(cn, 'missing reverse DNS for {}'.format(ip)) + + # TODO: remove these from schema + for puppet_var in attrs.get('puppetVar', []): + complain(cn, 'has puppetVar: {}'.format(puppet_var)) + + for puppet_class in attrs.get('puppetClass', []): + complain(cn, 'has puppetClass: {}'.format(puppet_class)) + + +def get_kadmin_users(filter_query): + """Using kadmin -l as root means that we don't have to get a kerberos + principal for this script and it can always check the local kerberos, which + is nice""" + process = subprocess.run( + ('kadmin', '-l', 'list', filter_query), + stdout=subprocess.PIPE, + ) + principals = process.stdout.decode('utf-8').split('\n') + + # Only return usernames, not the full principal name, since the suffix + # isn't useful + return {principal.split('/')[0] for principal in principals} + + +def get_users_in_group(c, group): + """This is useful over the (much simpler) list_group from ocflib since it + uses the local LDAP instead of the cached user groups, so it validates the + actual source of truth and can also work on dev-ldap""" + c.search( + OCF_LDAP_GROUP, + '(cn={})'.format(group), + attributes=['memberUid'], + ) + return set(c.response[0]['attributes']['memberUid']) + + +def check_users(c, complain): + ocfstaff = get_users_in_group(c, 'ocfstaff') + ocfroot = get_users_in_group(c, 'ocfroot') + + admin_users = get_kadmin_users('*/admin') + root_users = get_kadmin_users('*/root') + + for user in (admin_users - root_users): + if user not in ('create', 'kadmin'): + complain(user + '/admin', 'no corresponding {}/root principal'.format(user)) + + for user in (root_users - admin_users): + complain(user + '/root', 'no corresponding {}/admin principal'.format(user)) + + for user in ocfroot: + if user not in ocfstaff: + complain(user, 'in ocfroot but not ocfstaff') + + if user not in root_users: + complain(user, 'in ocfroot but has no root principal') + + if user not in admin_users: + complain(user, 'in ocfroot but has no admin principal') + + +def main(): + retval = 0 + + def complain(cn, error): + nonlocal retval + retval = 1 + print(bold(red('[{}] '.format(cn))) + error) + + with ldap_connection('localhost') as c: + check_hosts(c, complain) + check_users(c, complain) + + return retval + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/modules/ldap-server/ocf.schema.ldif b/modules/ldap-server/ocf.schema.ldif new file mode 100644 index 00000000..ced30fa8 --- /dev/null +++ b/modules/ldap-server/ocf.schema.ldif @@ -0,0 +1,86 @@ +dn: cn=ocf,cn=schema,cn=config +objectClass: olcSchemaConfig +cn: ocf +olcAttributeTypes: ( 1.3.6.1.4.1.41759.1.2.1 + NAME 'calnetUid' + DESC 'An integer identifying a user in the CalNet Directory' + EQUALITY integerMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) +olcAttributeTypes: ( 1.3.6.1.4.1.41759.1.3.1 + NAME 'oslGid' + DESC 'An integer identifying a student organization (deprecated)' + EQUALITY integerMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) +olcAttributeTypes: ( 1.3.6.1.4.1.41759.1.4.1 + NAME 'callinkOid' + DESC 'An integer identifying an organization in CalLink' + EQUALITY integerMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 + SINGLE-VALUE ) +olcAttributeTypes: ( 1.3.6.1.4.1.41759.1.5.1 + NAME 'lastRenewal' + DESC 'Last virtual host renewal date' + EQUALITY generalizedTimeMatch + ORDERING generalizedTimeOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 + SINGLE-VALUE ) +olcAttributeTypes: ( 1.3.6.1.4.1.41759.1.6.1 + NAME 'creationTime' + DESC 'Account creation date' + EQUALITY generalizedTimeMatch + ORDERING generalizedTimeOrderingMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 + SINGLE-VALUE ) +olcAttributeTypes: ( 1.3.6.1.4.1.41759.1.7.1 + NAME 'ocfEmail' + DESC 'OCF email address' + EQUALITY caseIgnoreIA5Match + SUBSTR caseIgnoreIA5SubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} + SINGLE-VALUE ) +olcObjectClasses: ( 1.3.6.1.4.1.41759.1.1.1 + NAME 'ocfAccount' + DESC 'Attributes for OCF accounts' + SUP posixAccount + AUXILIARY + MUST ( cn $ uid $ uidNumber $ gidNumber $ homeDirectory $ loginShell ) + MAY ( calnetUid $ oslGid $ callinkOid $ mail $ lastRenewal $ creationTime $ ocfEmail ) ) +olcAttributeTypes: ( 1.3.6.1.4.1.41759.2.2.1 + NAME 'type' + DESC 'Host type' + EQUALITY caseIgnoreIA5Match + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 + SINGLE-VALUE ) +olcAttributeTypes: ( 1.3.6.1.4.1.41759.2.2.2 + NAME 'dnsCname' + DESC 'DNS CNAME record' + EQUALITY caseIgnoreMatch + SUBSTR caseIgnoreSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 ) +olcAttributeTypes: ( 1.3.6.1.4.1.41759.2.2.3 + NAME 'dnsA' + DESC 'DNS A record' + EQUALITY caseIgnoreMatch + SUBSTR caseIgnoreSubstringsMatch + SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 ) +olcAttributeTypes: ( 1.3.6.1.4.1.41759.2.2.4 + NAME 'ip6HostNumber' + DESC 'IPv6 address, as represented by RFC1884 section 2.2.2' + EQUALITY caseIgnoreIA5Match + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{128} ) +olcObjectClasses: ( 1.3.6.1.4.1.41759.2.1.2 + NAME 'ip6Host' + DESC 'Host optionally supporting IPv6' + SUP top + AUXILIARY + MUST ( cn ) + MAY ( ip6HostNumber $ manager $ description $ l ) ) +olcObjectClasses: ( 1.3.6.1.4.1.41759.2.1.1 + NAME 'ocfDevice' + DESC 'Attributes for OCF hosts' + SUP ( ipHost $ ip6Host $ ieee802Device $ puppetClient ) + AUXILIARY + MUST ( cn $ type ) + MAY ( environment $ dnsCname $ dnsA ) ) diff --git a/modules/ldap-server/puppet.schema.ldif b/modules/ldap-server/puppet.schema.ldif new file mode 100644 index 00000000..40b882b2 --- /dev/null +++ b/modules/ldap-server/puppet.schema.ldif @@ -0,0 +1,25 @@ +dn: cn=puppet,cn=schema,cn=config +objectClass: olcSchemaConfig +cn: puppet +olcAttributeTypes: ( 1.3.6.1.4.1.34380.1.1.3.10 NAME 'puppetClass' + DESC 'Puppet Node Class' + EQUALITY caseIgnoreIA5Match + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) +olcAttributeTypes: ( 1.3.6.1.4.1.34380.1.1.3.9 NAME 'parentNode' + DESC 'Puppet Parent Node' + EQUALITY caseIgnoreIA5Match + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 + SINGLE-VALUE ) +olcAttributeTypes: ( 1.3.6.1.4.1.34380.1.1.3.11 NAME 'environment' + DESC 'Puppet Node Environment' + EQUALITY caseIgnoreIA5Match + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) +olcAttributeTypes: ( 1.3.6.1.4.1.34380.1.1.3.12 NAME 'puppetVar' + DESC 'A variable setting for puppet' + EQUALITY caseIgnoreIA5Match + SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 ) +olcObjectClasses: ( 1.3.6.1.4.1.34380.1.1.1.2 NAME 'puppetClient' + SUP top + AUXILIARY + DESC 'Puppet Client objectclass' + MAY ( puppetClass $ parentNode $ environment $ puppetVar ) ) diff --git a/pkgs/ldap-git-backup.nix b/pkgs/ldap-git-backup.nix new file mode 100644 index 00000000..aa4d6ef3 --- /dev/null +++ b/pkgs/ldap-git-backup.nix @@ -0,0 +1,42 @@ +{ + stdenv, + fetchFromGitHub, + perl, + perlPackages, + git, + makeWrapper, + autoreconfHook, +}: + +stdenv.mkDerivation { + pname = "ldap-git-backup"; + version = "unstable-2023-01-27"; + + src = fetchFromGitHub { + owner = "elmar"; + repo = "ldap-git-backup"; + rev = "6e0ea0e9bd2b8a52965b06e63c30100508a29428"; + hash = "sha256-En8MBrSRj2zAs+/3XMRhT96UplkpawdBy3OCLYCWn0s="; + }; + + nativeBuildInputs = [ + autoreconfHook + makeWrapper + perl + ]; + + postInstall = '' + # Replace $repo->command('add', @filelist) with $repo->command('add', '-A') + # to avoid E2BIG (errno 7) when the LDAP database has thousands of entries. + # git add -A also handles deletions, so the explicit rm command is redundant. + sed -i \ + -e "s/\\\$repo->command('add', @filelist) if @filelist;/\\\$repo->command('add', '-A');/" \ + -e "/\\\$repo->command('rm', (keys %files_before)) if %files_before;/d" \ + $out/sbin/ldap-git-backup + patchShebangs $out/sbin/ldap-git-backup + wrapProgram $out/sbin/ldap-git-backup \ + --prefix PATH : ${git}/bin \ + --prefix PERL5LIB : ${perlPackages.Git}/${perl.libPrefix} \ + --prefix PERL5LIB : ${perlPackages.Error}/${perl.libPrefix} + ''; +} diff --git a/profiles/base.nix b/profiles/base.nix index 1a7ea768..5cd5da32 100644 --- a/profiles/base.nix +++ b/profiles/base.nix @@ -184,6 +184,7 @@ in iftop tcpdump whois + openssl # Other useful stuff tmux diff --git a/secrets/host-keys/eel.pub b/secrets/host-keys/eel.pub new file mode 100644 index 00000000..98d5cce7 --- /dev/null +++ b/secrets/host-keys/eel.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINSvB6e06YbGs4+TXaU+8sL+dWbuGB1A20y5A721faMv root@eel diff --git a/secrets/master-keyed/eel/ldap-github-deploy-key.age b/secrets/master-keyed/eel/ldap-github-deploy-key.age new file mode 100644 index 00000000..ed8d68c1 Binary files /dev/null and b/secrets/master-keyed/eel/ldap-github-deploy-key.age differ diff --git a/secrets/master-keyed/keytabs/eel.age b/secrets/master-keyed/keytabs/eel.age new file mode 100644 index 00000000..cca7d366 Binary files /dev/null and b/secrets/master-keyed/keytabs/eel.age differ diff --git a/secrets/master-keyed/keytabs/ldap-eel.age b/secrets/master-keyed/keytabs/ldap-eel.age new file mode 100644 index 00000000..b3fd0ed7 Binary files /dev/null and b/secrets/master-keyed/keytabs/ldap-eel.age differ diff --git a/secrets/master-keyed/ldap-keytab.age b/secrets/master-keyed/ldap-keytab.age new file mode 100644 index 00000000..26a41d46 --- /dev/null +++ b/secrets/master-keyed/ldap-keytab.age @@ -0,0 +1,33 @@ +age-encryption.org/v1 +-> X25519 5SCrTcaXp0LibOKckVIHM5hK7zNq1mctZ6fmWHEulW0 ++Po/64WCj1L6BPpRYX2JDUOFrc4KqOrdZ/3spVaJJDs +-> X25519 FUShX714016uOGf9EKrm5EDixL3Ke4sojToPy+NUgVE +B+Osrhp8WIm+hy8ikl1Z9RXBUCzyu5f08MFFJFrhIcs +-> X25519 1pw5BvHj3Cp7QUycF72jS+Rm+RuEyISt9PtJr/p37hU +vPtmDPuT3wqF69zEhcFiLwt7eleZfn/crPk4DdPuQZw +-> X25519 yQT81fgWo7pFji8WvTMAuG6bEFO2d2NbPg4p6s7zEDs +UIyI9BeKr93wEZwj/nXXnqPWkx4X7r42GrduoU92bkQ +-> X25519 nYofo9ZMcDQ8x5E+1UUO9cT4fl5VyTdMCTyE2dR+6wk +mHslWdJSREzwWmP0GlaxEnt3RqcD8pQDmHiDV8z8Xo0 +-> X25519 De6nHmhwGd41YpOwX4KpJvbDQ11B/aefZjkwwskV1To +lQzksmLcI4AU7XWArUbVo5HUKbZ+bqciugV2XPLqacQ +-> X25519 ZnU6bJoauG7oPs2/ZZJjt5O40fuNfe2qfmGlhBe+kgM +H4sCXpGsy5wWj+FzoxSRzPnhuYDHjrVRq1Vg4RGTc5c +-> X25519 RCq5WpEn4b7QekgxHm6h6VPXeubBUQWuua1pxRkwOgs +4hOBPwcfvINejpr2bgh07TBfTlGRn0sfjsqMBSdOgIU +-> X25519 Fzno0P9enyaXxfbk+2g/mqS+e3WBTH4d6NRCbriFJjY +cZytYVw7ypdJZzkCYgP7SX+9LUFeXBM0R80qnAMsCUA +-> X25519 8yH+f4cDs5DY3HKmWM7z0vxRegfoe5cY8ZlbHrUy8UQ +VgU6qHo+bgz4igqQUeB+I/38lWR+mJGoZzJ8L8aKRRU +-> X25519 4okyRDl1Xu711WWPWusHp6lpnbwAv2P43WnW7pN1uTU +THtwGxu1CYCj7OjInpiaSos5lUoT/KfKWHwsL7O4XIw +-> X25519 tUQukStJ0BNBxjXolILdGlpX9eBpfCWrzcsiGDsUbkA +PQbmYEOP80MaDZDVMU/yRxk3DlkjkCpJK54jrEhX/CM +-> X25519 jf0W0MJY3nEMGfAbLcFwfFRMNFaatjiytE1d2AiLT1I +sEpCpTqrvNmLHIw79wH6HwHNwZnZRhh1o2x3hdH9D/c +-> X25519 yF9mnuOBupDvOdwThXmdaGoiZaRNDUT5ae/exqsePho +V23FbZp+/3mnUELXL6GgE45xhmTo3jr3kq9rnpcFeog +-> X25519 +5d24DL3mXNWOdjKlEj2gMvrmwGD+XhlXmELs+U5TEk +hOwLTk7bml4cEWR5Ven0qdmb9epiPqIO9ZWOZf5wSdI +--- gvrteauyq4/nq4jp61GraPojn8Qq+mUB0hG88smJNbw +���o�LoPD�?�X��,�L�g��W�?���}!M��B!Tgi�ܵ O����klF_ ?�-��S��X�n�D>`R~�}N��$��.G�g�E}P}#��@=�m�j���Ͱc���T��8�Cq� \ No newline at end of file diff --git a/secrets/rekeyed/eel/2464f6f867decfb4ffaf7f6fbf921185-tsig-secret.age b/secrets/rekeyed/eel/2464f6f867decfb4ffaf7f6fbf921185-tsig-secret.age new file mode 100644 index 00000000..2329750f Binary files /dev/null and b/secrets/rekeyed/eel/2464f6f867decfb4ffaf7f6fbf921185-tsig-secret.age differ diff --git a/secrets/rekeyed/eel/557d256bed8119c6abe7ed5b54c9de09-ldap-github-deploy-key.age b/secrets/rekeyed/eel/557d256bed8119c6abe7ed5b54c9de09-ldap-github-deploy-key.age new file mode 100644 index 00000000..7f84f5ba Binary files /dev/null and b/secrets/rekeyed/eel/557d256bed8119c6abe7ed5b54c9de09-ldap-github-deploy-key.age differ diff --git a/secrets/rekeyed/eel/bb807fbdc11fa52683c3781117a7a157-root-password-hash.age b/secrets/rekeyed/eel/bb807fbdc11fa52683c3781117a7a157-root-password-hash.age new file mode 100644 index 00000000..5f125f62 --- /dev/null +++ b/secrets/rekeyed/eel/bb807fbdc11fa52683c3781117a7a157-root-password-hash.age @@ -0,0 +1,7 @@ +age-encryption.org/v1 +-> ssh-ed25519 O51D5A o2S4DSBLfT3iLzLhyciCdMVKPJ4DcNRnZ3K6gknecm0 +ku3ciNmHYjB5l4j9zyZrVNFrbKRPZatxmxl4shsbR6E +-> A[MB-grease |q5Z pf +wbfXcZTzozPjeeIqpfCy/chx9ToQ1ghY +--- KSWvi0F9yMLDk+DsP1k81xpB8+t4a7IywUznzD94P6M +��~��2Cl{�C[�� S�[2;�!\~ \���~�Buk�P���pD��Y��|�>�����g�#��< y�K�I� ����+U=_s�,Ia7�wq�y� \ No newline at end of file diff --git a/secrets/rekeyed/eel/c49c907d0ffd01fb63adee3118be0610-ldap-keytab.age b/secrets/rekeyed/eel/c49c907d0ffd01fb63adee3118be0610-ldap-keytab.age new file mode 100644 index 00000000..25a44210 Binary files /dev/null and b/secrets/rekeyed/eel/c49c907d0ffd01fb63adee3118be0610-ldap-keytab.age differ diff --git a/secrets/rekeyed/eel/e5d57f0ea904f5a1e1fecbf6e3684337-krb5-keytab.age b/secrets/rekeyed/eel/e5d57f0ea904f5a1e1fecbf6e3684337-krb5-keytab.age new file mode 100644 index 00000000..dea316a6 Binary files /dev/null and b/secrets/rekeyed/eel/e5d57f0ea904f5a1e1fecbf6e3684337-krb5-keytab.age differ