From 1a4f8870723b30709f413e157877f8a2fe9c117a Mon Sep 17 00:00:00 2001 From: Theo S Schult <theo.sschult@gmail.com> Date: Wed, 19 Feb 2025 09:11:26 -0300 Subject: [PATCH] Finish user create prototype --- .gitignore | 1 - cmd/root.go | 3 +- cmd/user.go | 2 +- cmd/user/create.go | 686 ++++++++++++++++++++++++++++++++++++++++----- cmd/user/delete.go | 62 +++- cmd/user/mod.go | 30 +- cmd/user/show.go | 239 +++++++++++----- model/user.go | 52 +++- 8 files changed, 904 insertions(+), 171 deletions(-) diff --git a/.gitignore b/.gitignore index 142bcc0..590d1aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ useradm -build.sh useradm.py full_commands.py diff --git a/cmd/root.go b/cmd/root.go index ac746c1..4e7fad1 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,12 +14,13 @@ var rootCmd = &cobra.Command{ with the cobra library that took inspiration from the old useradm.py. It takes care of LDAP configuration and was made as an update for the previous version.`, + SilenceErrors: true, } func Execute() { err := rootCmd.Execute() if err != nil { - fmt.Fprintf(os.Stderr, "cmd: %v", err) + fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) os.Exit(1) } } diff --git a/cmd/user.go b/cmd/user.go index 3208b8d..9ca388f 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -11,7 +11,7 @@ var userCmd = &cobra.Command{ } func init() { - userCmd.AddCommand(user.CreateCmd) + userCmd.AddCommand(user.CreateUserCmd) userCmd.AddCommand(user.DeleteCmd) userCmd.AddCommand(user.ModCmd) userCmd.AddCommand(user.ShowCmd) diff --git a/cmd/user/create.go b/cmd/user/create.go index 57a5e16..56f936c 100644 --- a/cmd/user/create.go +++ b/cmd/user/create.go @@ -1,16 +1,32 @@ package user import ( - "os" "fmt" - "bufio" + "log" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" "strings" + "time" + "github.com/go-ldap/ldap/v3" "github.com/spf13/cobra" "gitlab.c3sl.ufpr.br/tss24/useradm/model" ) -var CreateCmd = &cobra.Command{ +const ( + MAIL_ALIAS_FILE = "/etc/aliases" + DEF_PERMISSION = 0700 + BLK_PERMISSION = 0000 + MAX_VARIANCE = 50 + MIN_UID = 1000 + MAX_UID = 6000 +) + +var CreateUserCmd = &cobra.Command{ Use: "create", Short: "Create new user", RunE: createUserFunc, @@ -18,103 +34,629 @@ var CreateCmd = &cobra.Command{ func init() { // Possible Flags - CreateCmd.Flags().StringP("grr", "r", "", "User GRR, required for ini type") - CreateCmd.Flags().StringP("type", "t", "ini", "Type of auto-generated login: ini, first or last") - CreateCmd.Flags().StringP("path", "w", "", "Path to webdir (e.g. path/login)") - CreateCmd.Flags().StringP("name", "n", "", "User full name (use quotes for spaces)") - CreateCmd.Flags().StringP("login", "l", "", "User login name (auto-generated if empty)") - CreateCmd.Flags().StringP("group", "g", "", "User initial group") - CreateCmd.Flags().StringP("shell", "s", "/bin/bash", "Full path to shell ") - CreateCmd.Flags().StringP("homedir", "d", "", "Home directory path (/home/group/login if empty)") - CreateCmd.Flags().StringP("password", "p", "", "User password (auto-generated if empty)") - CreateCmd.Flags().StringP("status", "a", "Free", "User status (Blocked/Free)") + CreateUserCmd.Flags().StringP("grr", "r", "_", "User GRR, required for ini type") + CreateUserCmd.Flags().StringP("type", "t", "ini", "Type of auto-generated login: ini, first or last") + CreateUserCmd.Flags().StringP("path", "w", "", "Full path to webdir, if not set don't create") + CreateUserCmd.Flags().StringP("name", "n", "_", "User full name, required, use quotes for spaces") + CreateUserCmd.Flags().StringP("login", "l", "", "User login name, auto-generated if empty") + CreateUserCmd.Flags().StringP("group", "g", "", "User base group, required") + CreateUserCmd.Flags().StringP("shell", "s", "/bin/bash", "Full path to shell") + CreateUserCmd.Flags().StringP("homedir", "d", "", "Home directory path, /home/group/login if empty") + CreateUserCmd.Flags().StringP("passwd", "p", "", "User password, auto-generated if empty") + CreateUserCmd.Flags().StringP("status", "a", "Active", "User status, Active or Blocked") + CreateUserCmd.Flags().StringP("resp", "i", "_", "Person responsible for the account") + CreateUserCmd.Flags().StringP("expiry", "e", "_", "Expiry date in format dd.mm.yy") + CreateUserCmd.Flags().StringP("course", "c", "_", "User course/minicourse, even temp ones") + CreateUserCmd.Flags().StringP("nobkp", "b", "", "User nobackup directory path") // Required Flags - CreateCmd.MarkFlagRequired("name") - CreateCmd.MarkFlagRequired("type") - CreateCmd.MarkFlagRequired("group") + CreateUserCmd.MarkFlagRequired("name") + CreateUserCmd.MarkFlagRequired("group") - CreateCmd.Flags().BoolP("confirm", "y", false, "Skip confirmation prompt") + CreateUserCmd.Flags().BoolP("confirm", "y", false, "Skip confirmation prompt") } func createUserFunc(cmd *cobra.Command, args []string) error { - confirm, err := cmd.Flags().GetBool("confirm") - if err != nil { - return err - } + success := false + // Creates model from users input + usr, confirm, err := createNewUserModel(cmd) + if err != nil { return err } + + defer func() { + if !success { + _ = delKerberosPrincipal(usr.UID) + _ = delUserLDAP(usr) + } + }() + + // Prints info for confirmation + fmt.Printf("%v\n", usr.FullToString()) + //fmt.Printf("%v\n Passwd: %v\n\n", usr.ToString(), usr.Password) + confirmationPrompt(confirm, "creation") + + // Generate the new password + if usr.Password == "[auto-generate]" { + usr.Password = genPassword() + } + + err = addUserLDAP(usr) + if err != nil { return err } + + err = addKerberosPrincipal(usr.UID) + if err != nil { return err } + + err = modKerberosPassword(usr.UID, usr.Password) + if err != nil { return err } + + err = createUserDirs(usr) + if err != nil { return err } + + fmt.Println("Done!") + fmt.Printf("\nUser Login: %v", usr.UID) + fmt.Printf("\nUser Password: %v\n\n", usr.Password) + fmt.Printf("%v:%v:%v:%v:%v:%v:%v:%v:%v:\n", usr.UID, usr.Password, usr.GID, + usr.Name, usr.Gecos, usr.GRR, usr.Shell, usr.Homedir, usr.Webdir) + + success = true + return nil +} + +func createNewUserModel(cmd *cobra.Command) (model.User, bool, error) { + var u model.User + users, err := getUsers() + if err != nil { return u, false, err } + userGRR, err := cmd.Flags().GetString("grr") + if err != nil { return u, false, err } + userResp, err := cmd.Flags().GetString("resp") + if err != nil { return u, false, err } userName, err := cmd.Flags().GetString("name") - if err != nil { - return err - } + if err != nil { return u, false, err } userType, err := cmd.Flags().GetString("type") - if err != nil { - return err - } + if err != nil { return u, false, err } userGID, err := cmd.Flags().GetString("group") - if err != nil { - return err - } - userGRR, err := cmd.Flags().GetString("grr") - if err != nil { - return err - } - userWebdir, err := cmd.Flags().GetString("path") - if err != nil { - return err - } - userLogin, err := cmd.Flags().GetString("login") - if err != nil { - return err - } + if err != nil { return u, false, err } + userUID, err := cmd.Flags().GetString("login") + if err != nil { return u, false, err } + confirm, err := cmd.Flags().GetBool("confirm") + if err != nil { return u, false, err } userShell, err := cmd.Flags().GetString("shell") - if err != nil { - return err - } - userHomedir, err := cmd.Flags().GetString("homedir") - if err != nil { - return err - } - userPassword, err := cmd.Flags().GetString("password") - if err != nil { - return err - } + if err != nil { return u, false, err } + userWebdir, err := cmd.Flags().GetString("path") + if err != nil { return u, false, err } userStatus, err := cmd.Flags().GetString("status") - if err != nil { - return err - } + if err != nil { return u, false, err } + userCourse, err := cmd.Flags().GetString("course") + if err != nil { return u, false, err } + userExpiry, err := cmd.Flags().GetString("expiry") + if err != nil { return u, false, err } + userHomedir, err := cmd.Flags().GetString("homedir") + if err != nil { return u, false, err } + userPassword, err := cmd.Flags().GetString("passwd") + if err != nil { return u, false, err } + userNobackup, err := cmd.Flags().GetString("nobkp") + if err != nil { return u, false, err } + + groups, err := getGroups() + if err != nil { return u, false, err } + + err = validateExpiry(userExpiry) + if err != nil { return u, false, err } + + err = validateStatus(userStatus) + if err != nil { return u, false, err } + + err = validateGID(groups, userGID) + if err != nil { return u, false, err } + + err = validateUID(users, userUID) + if err != nil { return u, false, err } + + err = validateGRR(users, userGRR) + if err != nil { return u, false, err } - user := model.User{ - UID: ifThenElse(userLogin != "", userLogin, "[auto-generate]"), + if userStatus == "Blocked" { userShell = "/bin/false" } + + if userType == "ini" && userGRR == "_" { + err := fmt.Errorf("GRR is required for \"ini\" login type") + return u, false, err + } + + if userUID == "" { + used, variance := true, 0 + for used { + userUID = genLogin(userName, userGRR, userType, variance) + used = userUID == "" || userUID == "_" || + loginExists(users, userUID) || + mailAliasExists(userUID) + variance++ + if variance > MAX_VARIANCE { + log.Fatalf("Could't generate login automatically, please inform the desired login\n") + } + } + } + + userHomedir = genHomedirPath(userUID, userHomedir, userGID) + err = validatePath(userHomedir) + if err != nil { return u, false, err } + + userNobackup = genNobackupPath(userUID, userGID, userNobackup) + err = validatePath(userNobackup) + if err != nil { return u, false, err } + + userWebdir = genWebdirPath(userUID, userWebdir) + err = validatePath(userWebdir) + if err != nil { return u, false, err } + + u = model.User{ + UID: userUID, GID: userGID, GRR: userGRR, + Resp: userResp, Name: userName, Ltype: userType, Shell: userShell, - Webdir: userWebdir, Status: userStatus, + Expiry: userExpiry, + Course: userCourse, + Webdir: userWebdir, Homedir: userHomedir, + Nobackup: userNobackup, Password: ifThenElse(userPassword != "", "[set]", "[auto-generate]"), } - fmt.Println(user.ToString()) - fmt.Printf (" Passwd: %v", user.Password) + u = genGecos(u) + + newUIDNumber, err := getNewUIDNumber() + if err != nil { + err = fmt.Errorf("Failed to generate new UIDNumber for user: %v", err) + return u, false, err + } + u.UIDNumber = newUIDNumber - if !confirm { - fmt.Print("Proceed with user creation? [y/N] ") - reader := bufio.NewReader(os.Stdin) - response, _ := reader.ReadString('\n') - if strings.TrimSpace(strings.ToLower(response)) != "y" { - fmt.Fprintln(os.Stderr, "Aborted.") - os.Exit(1) - } - } + for key, value := range groups { + if value == u.GID { + u.GIDNumber = key + } + } - fmt.Println("Done!") + u.DN = "uid=" + u.UID + ",ou=usuarios,dc=c3local" - return nil + return u, confirm, nil } func ifThenElse(condition bool, a string, b string) string { if condition { return a } return b } + +func genHomedirPath(login string, homedir string, group string) string { + if homedir != "" { return homedir } + return filepath.Join("/home", group, login) +} + +func genWebdirPath(login string, webdir string) string { + if webdir != "" { return webdir } + return filepath.Join("/home/html/inf", login) +} + +func genNobackupPath(login string, group string, nobkp string) string { + if nobkp != "" { return nobkp } + return filepath.Join("/nobackup", group, login) +} + +func genGecos(user model.User) model.User { + gecos := user.UID + "," + gecos += user.GRR + "," + gecos += user.Resp + "," + gecos += user.Course + "," + gecos += user.Status + "," + gecos += user.Expiry + "," + gecos += user.Ltype + "," + gecos += user.Webdir + "," + gecos += user.Nobackup + user.Gecos = gecos + return user +} + +func validatePath(path string) error { + _, err := os.Stat(path) + if os.IsNotExist(err) { return nil } + return fmt.Errorf("Path \"%v\" already exists, please provide a new path", path) +} + +func validateExpiry(expiry string) error { + if expiry == "_" { return nil } + + parts := strings.Split(expiry, ".") + if !isValidDate(parts) { + err := fmt.Errorf("Malformed expiry date string, use \"dd.mm.yy\"") + return err + } + return nil +} + +func isValidDate(arr []string) bool { + if len(arr) != 3 { + return false + } + + // Convert to int + day, err1 := strconv.Atoi(arr[0]) + month, err2 := strconv.Atoi(arr[1]) + year, err3 := strconv.Atoi(arr[2]) + + if err1 != nil || err2 != nil || err3 != nil { + return false + } + + // Ensure year is two digits + if year < 0 || year > 99 { + return false + } + + // Validate the date + fullYear := 2000 + year + t := time.Date(fullYear, time.Month(month), day, 0, 0, 0, 0, time.UTC) + return t.Day() == day && t.Month() == time.Month(month) +} + +func genLoginIni(name string, grr string, variance int) string { + login := "" + parts := formatName(name) + + if len(parts) == 0 { + return "_" + } + x := variance / len(parts) + y := variance % len(parts) + + for i := range parts { + if y != 0 { + login += parts[i][:x+2] + y-- + } else { + login += parts[i][:x+1] + } + } + + login += grr[2:4] + + return login +} + +func genLoginFirst(name string, variance int) string { + parts := formatName(name) + + if len(parts) == 0 { + return "_" + } + + // Separate first name + first, parts := parts[0], parts[1:] + login := first + + variance++ + x := variance / len(parts) + y := variance % len(parts) + + for i := range parts { + if y != 0 { + login += parts[i][:x+1] + y-- + } else { + login += parts[i][:x] + } + } + + return login +} + +func genLoginLast(name string, variance int) string { + login := "" + parts := formatName(name) + + if len(parts) <= 1 { + return "_" + } + + // Separate last name + last, parts := parts[len(parts)-1], parts[:len(parts)-1] + + x := variance / len(parts) + y := variance % len(parts) + + for i := range parts { + if y != 0 { + login += parts[i][:x+2] + y-- + } else { + login += parts[i][:x+1] + } + } + + login += last + + return login +} + +func genLogin(name string, grr string, ltype string, variance int) string { + var login string + switch ltype { + case "", "ini": + login = genLoginIni(name, grr, variance) + case "first": + login = genLoginFirst(name, variance) + case "last": + login = genLoginLast(name, variance) + } + return login +} + +func validateStatus(status string) error { + if status != "Blocked" && status != "Active" { + err := fmt.Errorf("User status can only be \"Active\" or \"Blocked\"") + return err + } + return nil +} + +func validateGRR(users []model.User, grr string) error { + if grr == "_" { return nil } + + isValid, _ := regexp.MatchString(`^\d{8}$`, grr) // is 8 digit number + if !isValid { + err := fmt.Errorf("Malformed GRR string, must be 8 digit number") + return err + } + + if grrExists(users, grr) { + err := fmt.Errorf(`The informed GRR already exists in LDAP database +Note: To search for the account use "useradm user show -r %s"`, grr) + return err + } + + return nil +} + +func validateGID(groups map[string]string, group string) error { + for _, value := range groups { + if value == group { + return nil + } + } + err := fmt.Errorf("Could't find group \"%v\" in LDAP database", group) + return err +} + +func validateUID(users []model.User, login string) error { + for i := range users { + if users[i].UID == login { + err := fmt.Errorf(`The informed Login already exists in LDAP database +Note: To search for the account use "useradm user show -l %s"`, login) + return err + } + } + return nil +} + +func formatName(name string) []string { + connectives := map[string]bool{"da": true, "de": true, "di": true, + "do": true, "das": true, "dos": true, "von": true} + splitName := strings.Fields(name) + var parts []string + + for _, part := range splitName { + lowerPart := strings.ToLower(part) + if !connectives[lowerPart] { + parts = append(parts, string(lowerPart)) + } + } + return parts +} + +// Queries to check if the alias exists +func mailAliasExists(alias string) bool { + cmd := exec.Command("/usr/sbin/postalias", "-q", alias, MAIL_ALIAS_FILE) + cmd.Stdout = nil + cmd.Stderr = nil + return cmd.Run() == nil +} + +func getNewUIDNumber() (string, error) { + var avail int + + uids, err := getUIDs() + + if err != nil { return "", err } + + for i := MIN_UID; i <= MAX_UID; i++ { + avail = sort.Search(len(uids), func(j int) bool { + return uids[j] >= i + }) + } + + if avail > MAX_UID { + err = fmt.Errorf("No more available UIDNumbers") + return "", err + } + + uid := strconv.Itoa(avail) + return uid, nil +} + +func addUserLDAP(u model.User) error { + l, err := connLDAP() + if err != nil { + return err + } + defer l.Close() + + req := ldap.NewAddRequest(u.DN, nil) + req.Attribute("uid", []string{u.UID}) + req.Attribute("cn", []string{u.Name}) + req.Attribute("objectClass", []string{"account", "posixAccount"}) + req.Attribute("loginShell", []string{u.Shell}) + req.Attribute("uidNumber", []string{u.UIDNumber}) + req.Attribute("gidNumber", []string{u.GIDNumber}) + req.Attribute("homeDirectory", []string{u.Homedir}) + req.Attribute("gecos", []string{u.Gecos}) + + err = l.Add(req) + if err != nil { + return fmt.Errorf("Failed to add user %s to LDAP: %v", u.UID, err) + } + + return nil +} + +func addKerberosPrincipal(login string) error { + cmd := exec.Command("kadmin.local", "-q", + fmt.Sprintf("addprinc -policy padrao -randkey -x dn=uid=%s,ou=usuarios,dc=c3local %s", login, login)) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("Failed to add Kerberos principal: %v\nOutput: %s", err, output) + } + + return nil +} + +func modKerberosPassword(login, password string) error { + cmd := exec.Command("kadmin.local", "-q", + fmt.Sprintf("cpw -pw %s %s", password, login)) + + cmd.Stdout = nil + cmd.Stderr = nil + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("Failed to change password for %s: %v\nOutput: %s", login, err, output) + } + + return nil +} + +func createUserDirs(u model.User) error { + success := false + defer func() { + if !success { + fmt.Println("Error found! Cleaning up...") + _ = DeleteHome(u.Nobackup) + _ = DeleteHome(u.Homedir) + } + }() + + if err := createHome(u, u.Homedir) + err != nil { return err } + + if err := createHome(u, u.Nobackup) + err != nil { return err } + + if err := createWeb(u) + err != nil { return err } + + success = true + return nil +} + +func DeleteHome(homeDir string) error { + return os.RemoveAll(homeDir) +} + +func createHome(u model.User, homeDir string) error { + var perm os.FileMode + + perm = DEF_PERMISSION + if u.Status == "Blocked" { + perm = BLK_PERMISSION + } + + // Create directory + if err := os.MkdirAll(homeDir, perm); err != nil { + return fmt.Errorf("Failed to create home directory: %w", err) + } + + + // Copy /etc/skel + cmd := exec.Command("cp", "-r", "/etc/skel/.", homeDir) + cmd.Stdout = nil + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return fmt.Errorf("Failed to copy /etc/skel contents: %w", err) + } + + uid, err := strconv.Atoi(u.UIDNumber) + if err != nil { + return fmt.Errorf("Failed to convert uid to int: %w", err) + } + gid, err := strconv.Atoi(u.GIDNumber) + if err != nil { + return fmt.Errorf("Failed to convert gid to int: %w", err) + } + // Change ownership + if err := os.Chown(homeDir, uid, gid); err != nil { + return fmt.Errorf("Failed to change ownership: %w", err) + } + + // Change permissions + if err := os.Chmod(homeDir, perm); err != nil { + return fmt.Errorf("Failed to set permissions: %w", err) + } + + return nil +} + +func createWeb(u model.User) error { + var perm os.FileMode + success := false + + perm = DEF_PERMISSION + if u.Status == "Blocked" { + perm = BLK_PERMISSION + } + + defer func() { + if !success { + _ = DeleteHome(u.Webdir) + } + }() + + // Create directory + if err := os.MkdirAll(u.Webdir, perm); err != nil { + return fmt.Errorf("Failed to create web directory: %w", err) + } + + // Create index + cmd := exec.Command("touch", filepath.Join(u.Webdir, "index.html")) + cmd.Stdout = nil + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return fmt.Errorf("Failed to create index.html: %w", err) + } + + // Create link in users home + cmd = exec.Command("ln", "-s", u.Webdir, filepath.Join(u.Homedir, "public_html")) + if err := cmd.Run(); err != nil { + return fmt.Errorf("Failed to create link public_html: %w", err) + } + + uid, err := strconv.Atoi(u.UIDNumber) + if err != nil { + return fmt.Errorf("Failed to convert uid to int: %w", err) + } + gid, err := strconv.Atoi(u.GIDNumber) + if err != nil { + return fmt.Errorf("Failed to convert gid to int: %w", err) + } + // Change ownership + if err := os.Chown(u.Webdir, uid, gid); err != nil { + return fmt.Errorf("Failed to change ownership: %w", err) + } + + // Change permissions + if err := os.Chmod(u.Webdir, perm); err != nil { + return fmt.Errorf("Failed to set permissions: %w", err) + } + + success = true + return nil +} diff --git a/cmd/user/delete.go b/cmd/user/delete.go index 2777308..b9e4bfa 100644 --- a/cmd/user/delete.go +++ b/cmd/user/delete.go @@ -1,8 +1,68 @@ package user -import "github.com/spf13/cobra" +import ( + "fmt" + "log" + "os/exec" + + "github.com/go-ldap/ldap/v3" + "github.com/spf13/cobra" + "gitlab.c3sl.ufpr.br/tss24/useradm/model" +) var DeleteCmd = &cobra.Command{ Use: "delete", Short: "Delete a user", } + +func delUserLDAP(u model.User) error { + l, err := connLDAP() + if err != nil { + return err + } + defer l.Close() + + // Remove user from all groups + searchReq := ldap.NewSearchRequest( + "ou=grupos,dc=c3local,dc=com", + ldap.ScopeWholeSubtree, + ldap.NeverDerefAliases, 0, 0, false, + "(memberUid=" + u.UID + ")", // Filter by UID membership + []string{"dn"}, + nil, + ) + + groups, err := l.Search(searchReq) + if err != nil && !ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) { + return fmt.Errorf("Group members search failed: %v", err) + } + + for _, entry := range groups.Entries { + modReq := ldap.NewModifyRequest(entry.DN, nil) + modReq.Delete("memberUid", []string{u.UID}) + if err := l.Modify(modReq); err != nil && + !ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchAttribute) { + log.Printf("Warning: Failed to remove from group %s: %v", entry.DN, err) + } + } + + // Delete user entry + delReq := ldap.NewDelRequest(u.DN, nil) + if err := l.Del(delReq); err != nil && + !ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) { + return fmt.Errorf("user deletion failed: %v", err) + } + + return nil +} + +func delKerberosPrincipal(login string) error { + cmd := exec.Command("kadmin.local", "-q", fmt.Sprintf("delprinc -force %s", login)) + + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("Fail to delete Kerberos principal: %v\nOutput: %s", err, output) + } + + return nil +} diff --git a/cmd/user/mod.go b/cmd/user/mod.go index 368802c..632d471 100644 --- a/cmd/user/mod.go +++ b/cmd/user/mod.go @@ -91,7 +91,7 @@ func modifyUserFunc(cmd *cobra.Command, args []string) error { userPasswordNew = "auto" } else if unblock { userShellNew = "/bin/bash" - userStatusNew = "Free!" + userStatusNew = "Active" userPasswordNew = "auto" } @@ -109,7 +109,7 @@ func modifyUserFunc(cmd *cobra.Command, args []string) error { if cmd.Flags().Changed("password") || block || unblock { if userPasswordNew == "auto" { - userPasswordNew = generatePassword() + userPasswordNew = genPassword() userMod.Password = "[auto-generated]" } else { userMod.Password = "[explicitly set]" @@ -120,15 +120,7 @@ func modifyUserFunc(cmd *cobra.Command, args []string) error { fmt.Println(userMod.ToString()) - if !confirm { - fmt.Print("Proceed with user update? [y/N] ") - reader := bufio.NewReader(os.Stdin) - response, _ := reader.ReadString('\n') - if strings.TrimSpace(strings.ToLower(response)) != "y" { - fmt.Fprintln(os.Stderr, "Aborted.") - os.Exit(1) - } - } + confirmationPrompt(confirm, "update") fmt.Println("Done!") @@ -137,7 +129,7 @@ func modifyUserFunc(cmd *cobra.Command, args []string) error { return nil } -func generatePassword() string { +func genPassword() string { const charset = "@*()=+[];,.?123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ" b := make([]byte, 20) if _, err := rand.Read(b); err != nil { @@ -149,3 +141,17 @@ func generatePassword() string { return string(b) } +func confirmationPrompt(confirm bool, operation string) { + if !confirm { + fmt.Printf("Proceed with user %v? [y/N] ", operation) + reader := bufio.NewReader(os.Stdin) + response, _ := reader.ReadString('\n') + if strings.TrimSpace(strings.ToLower(response)) != "y" { + fmt.Fprintln(os.Stderr, "Aborted.") + os.Exit(1) + } + } +} + + + diff --git a/cmd/user/show.go b/cmd/user/show.go index 0e42af1..8be66eb 100644 --- a/cmd/user/show.go +++ b/cmd/user/show.go @@ -3,7 +3,8 @@ package user import ( "os" "fmt" - "log" + "sort" + "strconv" "strings" "github.com/spf13/cobra" @@ -11,6 +12,11 @@ import ( "gitlab.c3sl.ufpr.br/tss24/useradm/model" ) +const ( + NUM_GECOS_FIELDS = 9 + PASSWD_PATH = "/etc/ldapscripts/ldapscripts.passwd" +) + var ShowCmd = &cobra.Command{ Use: "show", Short: "Search for users and show info", @@ -23,7 +29,7 @@ func init() { ShowCmd.Flags().StringP("name", "n", "", "Search by user name (case sensitive)") ShowCmd.Flags().StringP("login", "l", "", "Search by login") ShowCmd.Flags().StringP("group", "g", "", "Search by user base group") - ShowCmd.Flags().StringP("status", "a", "", "Search by user status (Free/Blocked)") + ShowCmd.Flags().StringP("status", "s", "", "Search by user status (Active/Blocked)") ShowCmd.Flags().StringP("homedir", "d", "", "Search by user homedir") // At least one is required! @@ -32,31 +38,19 @@ func init() { func searchUser(cmd *cobra.Command, args []string) error { grr, err := cmd.Flags().GetString("grr") - if err != nil { - return err - } + if err != nil { return err } name, err := cmd.Flags().GetString("name") - if err != nil { - return err - } + if err != nil { return err } login, err := cmd.Flags().GetString("login") - if err != nil { - return err - } + if err != nil { return err } group, err := cmd.Flags().GetString("group") - if err != nil { - return err - } + if err != nil { return err } homedir, err := cmd.Flags().GetString("homedir") - if err != nil { - return err - } + if err != nil { return err } status, err := cmd.Flags().GetString("status") - if err != nil { - return err - } - - users := getUsers() + if err != nil { return err } + users, err := getUsers() + if err != nil { return err } filtered := Filter(users, func(u model.User) bool { return (grr == "" || strings.Contains(u.GRR, grr)) && @@ -85,25 +79,16 @@ func Filter[T any](slice []T, predicate func(T) bool) []T { return result } -func getUsers() []model.User { - // Connect to the LDAP server - l, err := ldap.DialURL("ldapi:///") - if err != nil { - log.Fatal(err) - } - defer l.Close() +func getUsers() ([]model.User, error) { - // Get admin credentials - password, err := getLDAPPassword("/etc/ldapscripts/ldapscripts.passwd") - if err != nil { - log.Fatalf("Failed to read LDAP password: %v", err) - } + var users []model.User - // Bind using admin credentials - err = l.Bind("cn=admin,dc=c3local", password) + // Connect + l, err := connLDAP() if err != nil { - log.Fatalf("Failed to bind with credentials: %v", err) + return users, err } + defer l.Close() // Create the LDAP search request searchRequest := ldap.NewSearchRequest( @@ -114,23 +99,28 @@ func getUsers() []model.User { 0, // time limit false, // types only "(objectClass=posixAccount)",// filter - []string{"dn", "uid", "cn", "loginShell", "uidNumber", "gidNumber", "homeDirectory", "gecos"}, // attributes to return + []string{"dn", "uid", "cn", "loginShell", "uidNumber", + "gidNumber", "homeDirectory", "gecos"}, // attributes to return nil, ) // Perform the search sr, err := l.Search(searchRequest) if err != nil { - log.Fatalf("Failed to make LDAP search: %v", err) + err = fmt.Errorf("Failed to fetch users from LDAP: %v", err) + return nil, err } - var users []model.User + // Get all the groups and ids + groups, err := getGroups() + if err != nil { + err = fmt.Errorf("Failed to fetch groups and gids from LDAP: %v", err) + return nil, err + } // Iterate over the search results for _, entry := range sr.Entries { - gecos := entry.GetAttributeValue("gecos") shell := entry.GetAttributeValue("loginShell") - gidNumber := entry.GetAttributeValue("gidNumber") user := model.User{ DN: entry.DN, @@ -138,17 +128,20 @@ func getUsers() []model.User { Name: entry.GetAttributeValue("cn"), Shell: shell, UIDNumber: entry.GetAttributeValue("uidNumber"), - GIDNumber: gidNumber, + GIDNumber: entry.GetAttributeValue("gidNumber"), Homedir: entry.GetAttributeValue("homeDirectory"), - Gecos: gecos, - Status: ifThenElse(shell == "/bin/bash", "Free", "Blocked"), + Gecos: entry.GetAttributeValue("gecos"), + Status: ifThenElse(shell == "/bin/bash", "Active", "Blocked"), } - // Query for the group name using gidNumber - groupName, err := getGroupNameByGID(l, gidNumber) - if err != nil { - log.Printf("Warning: Failed to find group name for gidNumber %s: %v", gidNumber, err) - user.GID = "Unknown" + user.GID = groups[user.GIDNumber] + + gidNumber := user.GIDNumber + + // Safe assignment + groupName, exists := groups[gidNumber] + if !exists { + fmt.Printf("WARNING: no group found for GIDNumber %s, user %s. Continuing...", gidNumber, user.UID) } else { user.GID = groupName } @@ -156,13 +149,41 @@ func getUsers() []model.User { user = parseGecos(user) users = append(users, user) } - return users + + return users, nil +} + +func connLDAP() (*ldap.Conn, error) { + // Connect to the LDAP server + l, err := ldap.DialURL("ldapi:///") + if err != nil { + err = fmt.Errorf("Failed to connect to LDAP: %v", err) + return nil, err + } + + // Get admin credentials + password, err := getLDAPPassword(PASSWD_PATH) + if err != nil { + err = fmt.Errorf("Failed to read LDAP password: %v", err) + return nil, err + } + + // Bind using admin credentials + err = l.Bind("cn=admin,dc=c3local", password) + if err != nil { + l.Close() + err = fmt.Errorf("Failed to bind with credentials: %v", err) + return nil, err + } + + return l, nil } func getLDAPPassword(path string) (string, error) { passwd, err := os.ReadFile(path) if err != nil { - return "", fmt.Errorf("Error reading password file: %w", err) + err = fmt.Errorf("Error reading password file: %w", err) + return "", err } password := strings.TrimSpace(string(passwd)) @@ -176,17 +197,19 @@ func getLDAPPassword(path string) (string, error) { // 2. Professor responsavel // 3. Curso/minicurso do usuario (ate para temporarios) // 4. Validade da conta (data maxima para delecao) -// 5. Status da conta (Blocked/Free) +// 5. Status da conta (Blocked/Active) // 6. Tipo de login (ini, first, last) -// 7. to be continued... +// 7. Webdir do usuario +// 8. Nobackup do usuario +// 9. to be continued... func parseGecos(user model.User) model.User { - result := [7]string{0: "_"} + result := [NUM_GECOS_FIELDS]string{0: "_"} for i := range result { result[i] = "_" } parts := strings.Split(user.Gecos, ",") - for i := 0; i < len(parts) && i < 7; i++ { + for i := 0; i < len(parts) && i < NUM_GECOS_FIELDS; i++ { if strings.TrimSpace(parts[i]) != "" { result[i] = parts[i] } @@ -195,37 +218,111 @@ func parseGecos(user model.User) model.User { user.GRR = result[1] user.Resp = result[2] user.Course = result[3] - user.Expiry = result[4] if user.Status == "_"{ user.Status = result[5] } + user.Expiry = result[4] user.Ltype = result[6] + user.Webdir = result[7] + user.Nobackup = result[8] return user } -// Helper function to get group name by gidNumber -func getGroupNameByGID(l *ldap.Conn, gidNumber string) (string, error) { + +func getGroups() (map[string]string, error) { + + groupMap := make(map[string]string) + + l, err := connLDAP() + if err != nil { + return groupMap, err + } + defer l.Close() + searchRequest := ldap.NewSearchRequest( - "dc=c3local", // search base - ldap.ScopeWholeSubtree, // scope - ldap.NeverDerefAliases, // aliases - 1, // size limit (one result expected) - 0, // time limit - false, // types only - fmt.Sprintf("(&(objectClass=posixGroup)(gidNumber=%s))", gidNumber), // filter by gidNumber - []string{"cn"}, // retrieve the group name (cn) + "dc=c3local", + ldap.ScopeWholeSubtree, + ldap.NeverDerefAliases, + 0, 0, + false, + "(&(objectClass=posixGroup))", + []string{"gidNumber", "cn"}, + nil, + ) + + sr, err := l.Search(searchRequest) + if err != nil { + return nil, fmt.Errorf("LDAP search failed: %w", err) + } + + for _, entry := range sr.Entries { + gid := entry.GetAttributeValue("gidNumber") + cn := entry.GetAttributeValue("cn") + if gid != "" && cn != "" { + groupMap[gid] = cn + } + } + + return groupMap, nil +} + +func getUIDs() ([]int, error) { + + var uidNumbers []int + + l, err := connLDAP() + if err != nil { + return uidNumbers, err + } + defer l.Close() + + searchRequest := ldap.NewSearchRequest( + "dc=c3local", + ldap.ScopeWholeSubtree, + ldap.NeverDerefAliases, + 0, 0, + false, + "(&(objectClass=posixAccount))", + []string{"uidNumber"}, nil, ) sr, err := l.Search(searchRequest) if err != nil { - return "", fmt.Errorf("LDAP search failed: %w", err) + return nil, fmt.Errorf("LDAP search failed: %w", err) } - if len(sr.Entries) == 0 { - return "", fmt.Errorf("no group found with gidNumber %s", gidNumber) + for _, entry := range sr.Entries { + uidStr := entry.GetAttributeValue("uidNumber") + if uidStr != "" { + uid, err := strconv.Atoi(uidStr) + if err != nil { + return nil, fmt.Errorf("invalid UID number: %s", uidStr) + } + uidNumbers = append(uidNumbers, uid) + } } - return sr.Entries[0].GetAttributeValue("cn"), nil + sort.Ints(uidNumbers) + + return uidNumbers, nil +} + +func loginExists(users []model.User, login string) bool { + return exists(users, func(u model.User) bool { return u.UID == login }) +} + +func grrExists(users []model.User, grr string) bool { + return exists(users, func(u model.User) bool { return u.GRR == grr }) || + exists(users, func(u model.User) bool { return u.GRR == "GRR" + grr }) +} + +func exists[T any](slice []T, predicate func(T) bool) bool { + for _, item := range slice { + if predicate(item) { + return true + } + } + return false } diff --git a/model/user.go b/model/user.go index 1b6cbac..901c8a4 100644 --- a/model/user.go +++ b/model/user.go @@ -4,21 +4,22 @@ import "fmt" type User struct { DN string - GRR string - UID string - GID string - Name string + GRR string + UID string + GID string + Name string Resp string - Ltype string + Ltype string Gecos string - Shell string + Shell string Course string Status string - Webdir string + Webdir string Expiry string - Homedir string - Password string - GIDNumber string + Homedir string + Password string + Nobackup string + GIDNumber string UIDNumber string } @@ -29,11 +30,38 @@ func (u *User) ToString() string { GRR: %s Group: %s Shell: %s - HomeDir: %s + Home: %s Webdir: %s + Nobkp: %s Status: %s Resp: %s Course: %s Expiry: %s`, - u.Name, u.UID, u.GRR, u.GID, u.Shell, u.Homedir, u.Webdir, u.Status, u.Resp, u.Course, u.Expiry) + u.Name, u.UID, u.GRR, u.GID, u.Shell, u.Homedir, u.Webdir, + u.Nobackup, u.Status, u.Resp, u.Course, u.Expiry) +} + +func (u *User) FullToString() string { + return fmt.Sprintf(`User: + DN: %s + GRR: %s + UID: %s + GID: %s + Name: %s + Resp: %s + Ltype: %s + Gecos: %s + Shell: %s + Course: %s + Status: %s + Webdir: %s + Expiry: %s + Homedir: %s + Password: %s + Nobackup: %s + GIDNumber: %s + UIDNumber: %s`, + u.DN, u.GRR, u.UID, u.GID, u.Name, u.Resp, u.Ltype, u.Gecos, + u.Shell,u.Course, u.Status, u.Webdir, u.Expiry, u.Homedir, + u.Password, u.Nobackup, u.GIDNumber, u.UIDNumber) } -- GitLab