// lldap-export exports the LLDAP directory as a canonical YAML snapshot // for use with the validator and migration tools. package main import ( "context" "flag" "fmt" "os" "strings" "keycape/internal/adapters/lldap" "keycape/internal/migration/lldapexport" "keycape/internal/server/telemetry" "keycape/internal/validator" "github.com/rs/zerolog" ) func main() { // Flags. url := flag.String("url", "ldap://localhost:389", "LLDAP server URL (ldap:// or ldaps://)") bindDN := flag.String("bind-dn", "", "Service account bind DN (required)") bindPW := flag.String("bind-pw", "", "Service account password (DEPRECATED: visible to any local user via ps; prefer KEYCAPE_LLDAP_BIND_PW or --bind-pw-file)") bindPWFile := flag.String("bind-pw-file", "", "File containing the service account password") baseDN := flag.String("base-dn", "", "LDAP search base DN (required)") output := flag.String("output", "canonical-export.yaml", "Output file path") tlsSkip := flag.Bool("tls-skip-verify", false, "Skip TLS certificate verification (dev only)") flag.Parse() if *bindDN == "" || *baseDN == "" { fmt.Fprintln(os.Stderr, "lldap-export: --bind-dn and --base-dn are required") flag.Usage() os.Exit(1) } password, err := resolveBindPassword(*bindPW, *bindPWFile) if err != nil { fmt.Fprintf(os.Stderr, "lldap-export: %v\n", err) os.Exit(1) } log := zerolog.New(os.Stderr).With().Timestamp().Logger() emitter := telemetry.NewLogEmitter(log) cfg := lldap.Config{ URL: *url, BindDN: *bindDN, BindPW: password, BaseDN: *baseDN, TLSSkipVerify: *tlsSkip, } repo := lldap.New(cfg) exp := lldapexport.New(repo, validator.ModeProvisioning, emitter) result, exportErr := exp.Export(context.Background(), *output) if exportErr != nil { fmt.Fprintf(os.Stderr, "lldap-export: export failed: %v\n", exportErr) os.Exit(1) } fmt.Fprintf(os.Stdout, "Exported %d users, %d groups to %s (group enumeration: %s)\n", len(result.Users), len(result.Groups), *output, result.GroupEnumeration) if result.GroupEnumeration != lldapexport.EnumerationDirectory { fmt.Fprintln(os.Stderr, "lldap-export: groups were derived from user memberships; groups with no members are absent") } if len(result.IncompatibilityReport) > 0 { fmt.Fprintln(os.Stderr, "Incompatibility report:") for _, item := range result.IncompatibilityReport { fmt.Fprintln(os.Stderr, " -", item) } os.Exit(2) // partial success: exported with warnings } } // bindPasswordEnv is the preferred way to supply the service account password. const bindPasswordEnv = "KEYCAPE_LLDAP_BIND_PW" // resolveBindPassword takes the password from the environment, a file, or the // deprecated flag, in that order. // // A password on argv is readable by any local user through ps and is captured by // shell history and process accounting, which is why the flag is deprecated // rather than merely discouraged (KEY-WP-0026). It still works, because the // migration scripts and existing runbooks use it, but it warns. func resolveBindPassword(flagValue, filePath string) (string, error) { if env := os.Getenv(bindPasswordEnv); env != "" { if flagValue != "" || filePath != "" { return "", fmt.Errorf("%s is set as well as a password flag; supply exactly one", bindPasswordEnv) } return env, nil } if filePath != "" { if flagValue != "" { return "", fmt.Errorf("--bind-pw and --bind-pw-file are mutually exclusive") } contents, err := os.ReadFile(filePath) if err != nil { return "", fmt.Errorf("read --bind-pw-file: %w", err) } // A password file almost always ends in a newline from the editor or // heredoc that wrote it; binding with it would fail confusingly. return strings.TrimRight(string(contents), "\r\n"), nil } if flagValue != "" { fmt.Fprintf(os.Stderr, "lldap-export: warning: --bind-pw exposes the password to any local user via ps; prefer %s or --bind-pw-file\n", bindPasswordEnv) return flagValue, nil } return "", fmt.Errorf("no password supplied: set %s, or pass --bind-pw-file", bindPasswordEnv) }