diff --git a/cmd/root.go b/cmd/root.go index 4e7fad1e2d9f8ab930c09b7d2382833ce2963931..76934e8282f0b48e11df356bc387e4cbb96ca0ed 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -14,6 +14,8 @@ 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.`, + // since we already print the errors in Execute() + // not having this would print the error twice :p SilenceErrors: true, } diff --git a/cmd/user.go b/cmd/user.go index 58d77b1ff3ea4c8e62c6f57db0c44da38f45a674..8358eb268a7da069f032e98ddb593a65590c052e 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -8,6 +8,9 @@ import ( var userCmd = &cobra.Command{ Use: "user", Short: "User subcommand", + Long: `Subcommand for managing unique users in general. +If what you are trying to do is create a lot of +new users, please do so with the bulk subcommand.`, } func init() { diff --git a/cmd/user/create.go b/cmd/user/create.go index 4937843b0e230a3aa68d26057c65f10d0939f4f4..94faae8de6f329f03ce2b737d9bebeca2ce7d569 100644 --- a/cmd/user/create.go +++ b/cmd/user/create.go @@ -3,8 +3,6 @@ package user import ( "os" "fmt" - "log" - "sort" "time" "regexp" "os/exec" @@ -18,15 +16,33 @@ import ( ) const ( - MAIL_ALIAS_FILE = "/etc/aliases" - DEF_PERMISSION = "0700" - BLK_PERMISSION = "0000" - HTML_BASE_PERM = "0755" - MAX_VARIANCE = 50 + MAIL_ALIAS_FILE = "/etc/aliases" // aliases db path for query + DEF_PERMISSION = "0700" // default user dir permission + BLK_PERMISSION = "0000" // user dir permission if blocked + HTML_BASE_PERM = "0755" // set public_html to this on creation + MAX_VARIANCE = 50 // tries made to create login automatically MIN_UID = 1000 MAX_UID = 6000 ) +type userOpts struct { + GRR string + GID string + UID string + Resp string + Name string + Nobkp string + Ltype string + Shell string + Passwd string + Webdir string + Status string + Course string + Expiry string + Homedir string + Confirm bool +} + var CreateUserCmd = &cobra.Command{ Use: "create", Short: "Create new user", @@ -34,7 +50,8 @@ var CreateUserCmd = &cobra.Command{ } func init() { - // Possible Flags + // possible flags + // FIXME: maybe leave less flags for user input 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, /home/html/inf/login if empty") @@ -50,16 +67,17 @@ func init() { CreateUserCmd.Flags().StringP("course", "c", "_", "User course/minicourse, even temp ones") CreateUserCmd.Flags().StringP("nobkp", "b", "", "User nobackup directory path") - // Required Flags + // required flags CreateUserCmd.MarkFlagRequired("name") CreateUserCmd.MarkFlagRequired("group") + CreateUserCmd.MarkFlagRequired("course") CreateUserCmd.Flags().BoolP("confirm", "y", false, "Skip confirmation prompt") } func createUserFunc(cmd *cobra.Command, args []string) error { success := false - // Creates model from users input + // creates model from users input usr, confirm, err := createNewUserModel(cmd) if err != nil { return err } @@ -70,12 +88,11 @@ func createUserFunc(cmd *cobra.Command, args []string) error { } }() - // Prints info for confirmation - //fmt.Printf("%v\n", usr.FullToString()) - fmt.Printf("%v\n Passwd: %v\n\n", usr.ToString(), usr.Password) + // prints info for confirmation + fmt.Printf("%v\n", usr.FullToString()) // for debug + //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() } @@ -102,205 +119,121 @@ func createUserFunc(cmd *cobra.Command, args []string) error { return nil } +// creates and validates user inputs into the User model 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 u, false, err } - userType, err := cmd.Flags().GetString("type") - if err != nil { return u, false, err } - userGID, err := cmd.Flags().GetString("group") - 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 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 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 } + if err != nil { + return u, false, err + } groups, err := getGroups() - if err != nil { return u, false, err } + if err != nil { + return u, false, err + } - err = validateExpiry(userExpiry) - if err != nil { return u, false, err } + opts, err := retrieveOpts(cmd) + if err != nil { + return u, false, err + } - err = validateStatus(userStatus) - if err != nil { return u, false, err } + if err := validateInputs(opts) + err != nil { + return u, false, err + } - err = validateGID(groups, userGID) - if err != nil { return u, false, err } + if opts.Status == "Blocked" { + opts.Shell = "/bin/false" + } - err = validateUID(users, userUID) - if err != nil { return u, false, err } + if opts.Ltype == "ini" && opts.GRR == "_" { + return u, false, fmt.Errorf("GRR is required for \"ini\" login type") + } - err = validateGRR(users, userGRR) - if err != nil { return u, false, err } + if opts.UID == "" { + opts.UID, err = genUniqueUID(opts.Name, opts.GRR, opts.Ltype, users) + if err != nil { + return u, false, err + } + } - if userStatus == "Blocked" { userShell = "/bin/false" } + opts.Homedir, err = genDirPath("/home", opts.GID, opts.UID, opts.Homedir) + if err != nil { + return u, false, err + } - if userType == "ini" && userGRR == "_" { - err := fmt.Errorf("GRR is required for \"ini\" login type") - return u, false, err + opts.Nobkp, err = genDirPath("/nobackup", opts.GID, opts.UID, opts.Nobkp) + if err != nil { + 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") - } - } + opts.Webdir, err = genDirPath("/home/html/inf", "", opts.UID, opts.Webdir) + if err != nil { + return u, false, err } - 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, - Status: userStatus, - Expiry: userExpiry, - Course: userCourse, - Webdir: userWebdir, - Homedir: userHomedir, - Nobackup: userNobackup, - Password: ifThenElse(userPassword != "", userPassword, "[auto-generate]"), - } + u = model.User{ + UID: opts.UID, + GID: opts.GID, + GRR: opts.GRR, + Resp: opts.Resp, + Name: opts.Name, + Ltype: opts.Ltype, + Shell: opts.Shell, + Status: opts.Status, + Expiry: opts.Expiry, + Course: opts.Course, + Webdir: opts.Webdir, + Homedir: opts.Homedir, + Nobackup: opts.Nobkp, + Password: ifThenElse(opts.Passwd != "", opts.Passwd, "[auto-generate]"), + } u = genGecos(u) - + + // get a new UIDNumber newUIDNumber, err := getNewUIDNumber() if err != nil { - err = fmt.Errorf("Failed to generate new UIDNumber for user: %v", err) - return u, false, err + return u, false, fmt.Errorf("failed to generate new UIDNumber for user: %v", err) } u.UIDNumber = newUIDNumber - for key, value := range groups { - if value == u.GID { + // assign GIDNumber by traversing the groups + for key, val := range groups { + if val == u.GID { u.GIDNumber = key + break } - } + } u.DN = "uid=" + u.UID + ",ou=usuarios,dc=c3local" - 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) + return u, opts.Confirm, nil } -func genWebdirPath(login string, webdir string) string { - if webdir != "" { return webdir } - return filepath.Join("/home/html/inf", login) +func genDirPath(base, group, login, input string) (string, error) { + if input != "" { return input, nil } + p := filepath.Join(base, group, login) + return p, validatePath(p) } -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.Name + "," - 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 genGecos(u model.User) model.User { + gecos := u.Name + "," + gecos += u.GRR + "," + gecos += u.Resp + "," + gecos += u.Course + "," + gecos += u.Status + "," + gecos += u.Expiry + "," + gecos += u.Ltype + "," + gecos += u.Webdir + "," + gecos += u.Nobackup + u.Gecos = gecos + return u } +// basically takes the initials and the grr year. +// if variance increases we take next letter of +// 1st name, then next letter of 2nd name, etc... func genLoginIni(name string, grr string, variance int) string { login := "" parts := formatName(name) @@ -320,11 +253,42 @@ func genLoginIni(name string, grr string, variance int) string { } } - login += grr[2:4] + return login + grr[2:4] +} - return login +// parent function for generating a login automatically +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 +} + +// removes connectives, leave all lowecase and splits the name +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 } +// basically takes 1st name and the initial of the 2nd. +// if variance increases we take more initials, and if +// it keeps going we put more letters in each name func genLoginFirst(name string, variance int) string { parts := formatName(name) @@ -352,6 +316,8 @@ func genLoginFirst(name string, variance int) string { return login } +// basically takes initials up to last name and then appends it. +// if variance increases we put more letters in each name func genLoginLast(name string, variance int) string { login := "" parts := formatName(name) @@ -380,82 +346,24 @@ func genLoginLast(name string, variance int) string { 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 - } +func genUniqueUID(name, grr, ltype string, users []model.User) (string, error) { + var uid string + used, variance := true, 0 + for used { + uid = genLogin(name, grr, ltype, variance) - 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 -} + // already taken or alias for it exists :( + used = loginExists(users, uid) || mailAliasExists(uid) -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 + variance++ + if variance > MAX_VARIANCE { + return "", fmt.Errorf("Could't generate login automatically, please inform the desired login\n") } } - 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 + return uid, nil } -// Queries to check if the alias exists +// 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 @@ -463,7 +371,8 @@ func mailAliasExists(alias string) bool { return cmd.Run() == nil } -func getNewUIDNumberLinear() (string, error) { +// finds next available uidNumber (MEX from uid group) +func getNewUIDNumber() (string, error) { uids, err := getUIDs() if err != nil { return "", err @@ -471,9 +380,9 @@ func getNewUIDNumberLinear() (string, error) { candidate := MIN_UID for _, uid := range uids { - if uid == candidate { // Check if taken + if uid == candidate { // check if taken candidate++ - } else if uid > candidate { // Found a gap + } else if uid > candidate { // found a gap break } } @@ -484,24 +393,7 @@ func getNewUIDNumberLinear() (string, error) { return strconv.Itoa(candidate), nil } -// Finds next available uidNumber -func getNewUIDNumber() (string, error) { - uids, err := getUIDs() - if err != nil { - return "", err - } - for candidate := MIN_UID; candidate <= MAX_UID; candidate++ { - i := sort.Search(len(uids), func(j int) bool { - return uids[j] >= candidate - }) - if i == len(uids) || uids[i] != candidate { - // Candidate UID is not in the list, so this is available. - return strconv.Itoa(candidate), nil - } - } - return "", fmt.Errorf("no more available UID numbers") -} - +// generates a LDAP request and adds the user to LDAP func addUserLDAP(u model.User) error { l, err := connLDAP() if err != nil { @@ -527,6 +419,7 @@ func addUserLDAP(u model.User) error { return nil } +// creates a KerberosPrincipal for the user 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)) @@ -539,6 +432,7 @@ func addKerberosPrincipal(login string) error { return nil } +// sets the password for the user (via kerberos) func modKerberosPassword(login, password string) error { cmd := exec.Command("kadmin.local", "-q", fmt.Sprintf("cpw -pw %s %s", password, login)) @@ -555,6 +449,7 @@ func modKerberosPassword(login, password string) error { func createUserDirs(u model.User) error { success := false + defer func() { if !success { fmt.Println("Error found creating dirs, cleaning up...") @@ -584,7 +479,7 @@ func createHome(u model.User, homeDir string) error { perm = BLK_PERMISSION } - // Create directory + // create directory cmd := exec.Command("mkdir", "-p", homeDir) cmd.Stdout = nil cmd.Stderr = nil @@ -592,19 +487,19 @@ func createHome(u model.User, homeDir string) error { return fmt.Errorf("Failed to create home directory: %w", err) } - // Copy /etc/skel + // copy /etc/skel cmd = exec.Command("cp", "-r", "/etc/skel/.", homeDir) if err := cmd.Run(); err != nil { return fmt.Errorf("Failed to copy /etc/skel contents: %w", err) } - // Change permissions + // change permissions cmd = exec.Command("chmod", perm, homeDir) if err := cmd.Run(); err != nil { return fmt.Errorf("Failed to set permissions: %w", err) } - // Change ownership + // change ownership cmd = exec.Command("chown", "-R", fmt.Sprintf("%s:%s", u.UID, u.GID), homeDir) if err := cmd.Run(); err != nil { return fmt.Errorf("Failed to change ownership: %w", err) @@ -627,7 +522,7 @@ func createWeb(u model.User) error { } }() - // Create directory + // create directory cmd := exec.Command("mkdir", "-p", u.Webdir) cmd.Stdout = nil cmd.Stderr = nil @@ -635,30 +530,31 @@ func createWeb(u model.User) error { return fmt.Errorf("Failed to create web directory: %w", err) } - // Create index + // create index cmd = exec.Command("touch", filepath.Join(u.Webdir, "index.html")) if err := cmd.Run(); err != nil { return fmt.Errorf("Failed to create index.html: %w", err) } - // Create link in users home + // 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) } - // Change permissions + // change permissions cmd = exec.Command("chmod", perm, u.Webdir) if err := cmd.Run(); err != nil { return fmt.Errorf("Failed to set permissions: %w", err) } - // Change ownership + // change ownership cmd = exec.Command("chown", "-R", fmt.Sprintf("%s:%s", u.UID, u.GID), u.Webdir) if err := cmd.Run(); err != nil { return fmt.Errorf("Failed to change ownership: %w", err) } + // set permission for public_html cmd = exec.Command("chmod", HTML_BASE_PERM, filepath.Join(u.Homedir, "public_html")) if err := cmd.Run(); err != nil { return fmt.Errorf("Failed to create set permissions for public_html: %w", err) @@ -667,3 +563,190 @@ func createWeb(u model.User) error { success = true return nil } + +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 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(grr string) error { + // OK if empty, only "ini" login type requires it and we check :) + if grr == "_" { return nil } + + users, err := getUsers() + if err != nil { return err } + + 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(group string) error { + var err error + + groups, err := getGroups() + if err != nil { return err } + + 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(login string) error { + users, err := getUsers() + if err != nil { return err } + res := searchUser(users, false, login, "", "", "", "", "") + if len(res) != 0 { + return fmt.Errorf(`The informed Login already exists in LDAP database +Note: To search for the account use "useradm user show -l %s"`, login) + } + return nil +} + +func validateInputs(opts userOpts) error { + var err error + + err = validateGID(opts.GID) + if err != nil { return err } + + err = validateGRR(opts.GRR) + if err != nil { return err } + + err = validateExpiry(opts.Expiry) + if err != nil { return err } + + err = validateStatus(opts.Status) + if err != nil { return err } + + // it’s OK if UID is empty here, we generate it later :) + if opts.UID != "" { + err := validateUID(opts.UID) + if err != nil { return err } + } + + return nil +} + +func getFlagString(cmd *cobra.Command, flag string) (string, error) { + flagValue, err := cmd.Flags().GetString(flag) + if err != nil { + return "", fmt.Errorf("Failed to get flag %q: %w", flag, err) + } + return flagValue, nil +} + +func retrieveOpts(cmd *cobra.Command) (userOpts, error) { + var opts userOpts + var err error + + opts.GRR, err = getFlagString(cmd, "grr") + if err != nil { return opts, err } + + opts.Resp, err = getFlagString(cmd, "resp") + if err != nil { return opts, err } + + opts.Name, err = getFlagString(cmd, "name") + if err != nil { return opts, err } + + opts.GID, err = getFlagString(cmd, "group") + if err != nil { return opts, err } + + opts.UID, err = getFlagString(cmd, "login") + if err != nil { return opts, err } + + opts.Ltype, err = getFlagString(cmd, "type") + if err != nil { return opts, err } + + opts.Shell, err = getFlagString(cmd, "shell") + if err != nil { return opts, err } + + opts.Webdir, err = getFlagString(cmd, "path") + if err != nil { return opts, err } + + opts.Nobkp, err = getFlagString(cmd, "nobkp") + if err != nil { return opts, err } + + opts.Status, err = getFlagString(cmd, "status") + if err != nil { return opts, err } + + opts.Course, err = getFlagString(cmd, "course") + if err != nil { return opts, err } + + opts.Expiry, err = getFlagString(cmd, "expiry") + if err != nil { return opts, err } + + opts.Passwd, err = getFlagString(cmd, "passwd") + if err != nil { return opts, err } + + opts.Homedir, err = getFlagString(cmd, "homedir") + if err != nil { return opts, err } + + opts.Confirm, err = cmd.Flags().GetBool("confirm") + if err != nil { return opts, err } + + return opts, nil +} + +func isValidDate(arr []string) bool { + if len(arr) != 3 { + return false + } + + // convert to int + day, err1 := strconv.Atoi(arr[0]) + mth, 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(mth), day, 0, 0, 0, 0, time.UTC) + return t.Day() == day && t.Month() == time.Month(mth) +} + +func ifThenElse(condition bool, a string, b string) string { + if condition { return a } + return b +} diff --git a/cmd/user/delete.go b/cmd/user/delete.go index bdc5c6d308cb20480a4f4dd8d97f1af696a5e6de..115af95ed6d00cb70b8859b91c06d52d1f4ef051 100644 --- a/cmd/user/delete.go +++ b/cmd/user/delete.go @@ -37,8 +37,10 @@ func init() { func deleteUserFunc(cmd *cobra.Command, args []string) error { success := false - userLogin, err := cmd.Flags().GetString("login") + + userLogin, err := getFlagString(cmd, "login") if err != nil { return err } + confirm, err := cmd.Flags().GetBool("confirm") if err != nil { return err } @@ -68,25 +70,31 @@ func deleteUserFunc(cmd *cobra.Command, args []string) error { if err != nil { return err } fmt.Printf("\nUser removed!\n") + success = true return nil } +// searches for the specified login string, there can only be one >:) func locateUser(login string) (model.User, error) { var u model.User users, err := getUsers() + if !loginExists(users, login) { return u, fmt.Errorf("Failed to find login in LDAP database: %v", err) } + filter := searchUser(users, false, login, "", "", "", "", "") if len(filter) != 1 { return u, fmt.Errorf(`More than one user matched the login given. search made: "useradm user show -l %v"`, login) } + u = filter[0] return u, nil } +// moves dirs to their respective trash dir func removeDirs(u model.User) error { err := moveAndChown(u.Homedir, HOME_TRASH, "nobody", "nogroup") if err != nil { return err } @@ -107,21 +115,23 @@ func delUserLDAP(u model.User) error { } defer l.Close() - // Remove user from all groups + // first 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 + "(memberUid=" + u.UID + ")", // filter by UID membership []string{"dn"}, nil, ) + // searches groups for target groups, err := l.Search(searchReq) if err != nil && !ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) { return fmt.Errorf("Group members search failed: %v", err) } + // removing from groups for _, entry := range groups.Entries { modReq := ldap.NewModifyRequest(entry.DN, nil) modReq.Delete("memberUid", []string{u.UID}) @@ -131,7 +141,7 @@ func delUserLDAP(u model.User) error { } } - // Delete user entry + // removing user entry delReq := ldap.NewDelRequest(u.DN, nil) if err := l.Del(delReq); err != nil && !ldap.IsErrorWithCode(err, ldap.LDAPResultNoSuchObject) { @@ -141,6 +151,7 @@ func delUserLDAP(u model.User) error { return nil } +// usually not necessary but used in a failed create command func delKerberosPrincipal(login string) error { cmd := exec.Command("kadmin.local", "-q", fmt.Sprintf("delprinc -force %s", login)) @@ -153,27 +164,27 @@ func delKerberosPrincipal(login string) error { } func moveAndChown(orig, dest, owner, group string) error { - // Check if orig exists + // check if orig exists if _, err := os.Stat(orig); err != nil { log.Printf("Directory %v not found so not moved\n", orig) return nil } - // Construct destination path + // construct destination path destPath := filepath.Join(dest, filepath.Base(orig)) if _, err := os.Stat(destPath); err == nil { - return fmt.Errorf("Directory %v already exists\n", destPath) + return fmt.Errorf("Directory %v already exists, can't move\n", destPath) } - // Move directory using shell command + // move directory cmd := exec.Command("mv", orig, destPath) if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("Failed to move dirs: %w\nOutput: %v", err, output) } - // Recursive chown using shell command - cmd = exec.Command("chown", "-R", owner+":"+group, destPath) + // recursive chown + cmd = exec.Command("chown", "-R", owner+":"+group, destPath) if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("Failed to set owner/group: %w\nOutput: %v", err, output) } diff --git a/cmd/user/mod.go b/cmd/user/mod.go index 632d471760ef3f41eaec49fcf96fcfccc16d7da7..856ed39641477f00930aa3bb3d937929948613bc 100644 --- a/cmd/user/mod.go +++ b/cmd/user/mod.go @@ -19,6 +19,7 @@ var ModCmd = &cobra.Command{ } func init() { + // possible flags ModCmd.Flags().StringP("grr", "r", "", "New user GRR") ModCmd.Flags().StringP("shell", "s", "", "New user shell") ModCmd.Flags().StringP("login", "l", "", "User login name") @@ -30,6 +31,7 @@ func init() { ModCmd.Flags().StringP("password", "p", "", "New password in plain text (use \"auto\" to autogenerate)") ModCmd.Flags().BoolP ("block", "b", false, "Block the user (changes the password and sets their shell to /bin/false)") + // required flags ModCmd.MarkFlagRequired("login") ModCmd.Flags().BoolP("confirm", "y", false, "Skip confirmation prompt") @@ -129,6 +131,7 @@ func modifyUserFunc(cmd *cobra.Command, args []string) error { return nil } +// do NOT include ':' in the charset, it WILL break the command func genPassword() string { const charset = "@*()=+[];,.?123456789abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ" b := make([]byte, 20) @@ -141,11 +144,14 @@ func genPassword() string { return string(b) } +// prints a confirmation prompt, given the operation being performed 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 dd3fb5549eba734ff560d9b405559c3ed55aca49..277a74a2a98bab5c5a96e214ff54ee880f30b2a1 100644 --- a/cmd/user/show.go +++ b/cmd/user/show.go @@ -13,7 +13,7 @@ import ( ) const ( - NUM_GECOS_FIELDS = 9 + NUM_GECOS_FIELDS = 9 // change if going to add new field PASSWD_PATH = "/etc/ldapscripts/ldapscripts.passwd" ) @@ -24,7 +24,7 @@ var ShowCmd = &cobra.Command{ } func init() { - // Possible search fields + // possible search fields ShowCmd.Flags().StringP("grr", "r", "", "Search by user GRR") ShowCmd.Flags().StringP("name", "n", "", "Search by user name (case sensitive)") ShowCmd.Flags().StringP("login", "l", "", "Search by login") @@ -32,27 +32,34 @@ func init() { ShowCmd.Flags().StringP("status", "s", "", "Search by user status (Active/Blocked)") ShowCmd.Flags().StringP("homedir", "d", "", "Search by user homedir") - ShowCmd.Flags().BoolP("ignore", "i", false, "Make the search case-insensitive") - - // At least one is required! + // at least one is required! ShowCmd.MarkFlagsOneRequired("grr", "name", "login", "group", "homedir", "status") + + ShowCmd.Flags().BoolP("ignore", "i", false, "Make the search case-insensitive") } func searchUserFunc(cmd *cobra.Command, args []string) error { - grr, err := cmd.Flags().GetString("grr") + grr, err := getFlagString(cmd,"grr") if err != nil { return err } - name, err := cmd.Flags().GetString("name") + + name, err := getFlagString(cmd,"name") if err != nil { return err } - login, err := cmd.Flags().GetString("login") + + login, err := getFlagString(cmd,"login") if err != nil { return err } - group, err := cmd.Flags().GetString("group") + + group, err := getFlagString(cmd,"group") if err != nil { return err } - homedir, err := cmd.Flags().GetString("homedir") + + homedir, err := getFlagString(cmd,"homedir") if err != nil { return err } - status, err := cmd.Flags().GetString("status") + + status, err := getFlagString(cmd,"status") if err != nil { return err } + ig, err := cmd.Flags().GetBool("ignore") if err != nil { return err } + users, err := getUsers() if err != nil { return err } @@ -75,7 +82,7 @@ func searchUser(users []model.User, ig bool, l, g, n, r, s, h string) []model.Us strings.ToLower(substr), ) } - return strings.Contains(src, substr) + return strings.Contains(src, substr) // normal search } // Search @@ -88,7 +95,7 @@ func searchUser(users []model.User, ig bool, l, g, n, r, s, h string) []model.Us }) } -// Generic function for filtering data types, in this case the model User +// generic function for filtering data types, in this case the model User func Filter[T any](slice []T, predicate func(T) bool) []T { var result []T for _, v := range slice { @@ -100,17 +107,15 @@ func Filter[T any](slice []T, predicate func(T) bool) []T { } func getUsers() ([]model.User, error) { - var users []model.User - // Connect l, err := connLDAP() if err != nil { return users, err } defer l.Close() - // Create the LDAP search request + // create the LDAP search request searchRequest := ldap.NewSearchRequest( "dc=c3local", // search base ldap.ScopeWholeSubtree, // scope @@ -124,21 +129,21 @@ func getUsers() ([]model.User, error) { nil, ) - // Perform the search + // perform the search sr, err := l.Search(searchRequest) if err != nil { err = fmt.Errorf("Failed to fetch users from LDAP: %v", err) return nil, err } - // Get all the groups and ids + // 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 + // iterate over the search results for _, entry := range sr.Entries { shell := entry.GetAttributeValue("loginShell") @@ -158,7 +163,7 @@ func getUsers() ([]model.User, error) { gidNumber := user.GIDNumber - // Safe assignment + // safe assignment :) groupName, exists := groups[gidNumber] if !exists { fmt.Printf("WARNING: no group found for GIDNumber %s, user %s. Continuing...", gidNumber, user.UID) @@ -174,21 +179,21 @@ func getUsers() ([]model.User, error) { } func connLDAP() (*ldap.Conn, error) { - // Connect to the LDAP server + // 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 + // 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 + // bind using admin credentials err = l.Bind("cn=admin,dc=c3local", password) if err != nil { l.Close() @@ -211,16 +216,16 @@ func getLDAPPassword(path string) (string, error) { return password, nil } -// Campos do GECOS: -// 0. Nome completo do titular da conta -// 1. GRR do aluno -// 2. Professor responsavel -// 3. Curso/minicurso do usuario (ate para temporarios) -// 4. Validade da conta (data maxima para delecao) -// 5. Status da conta (Blocked/Active) -// 6. Tipo de login (ini, first, last) -// 7. Webdir do usuario -// 8. Nobackup do usuario +// Gecos Fields: +// 0. users full name +// 1. users grr +// 2. responsible professor +// 3. users course/minicourse (even temp) +// 4. account expiry date (may be used for deletion) +// 5. account status (Blocked/Active) +// 6. login type (ini, first or last) +// 7. users webdir +// 8. users nobackup dir // 9. to be continued... func parseGecos(user model.User) model.User { result := [NUM_GECOS_FIELDS]string{0: "_"} @@ -238,10 +243,11 @@ func parseGecos(user model.User) model.User { user.GRR = result[1] user.Resp = result[2] user.Course = result[3] + user.Expiry = result[4] + // only set with gecos if empty if user.Status == "_"{ user.Status = result[5] } - user.Expiry = result[4] user.Ltype = result[6] user.Webdir = result[7] user.Nobackup = result[8] @@ -249,9 +255,7 @@ func parseGecos(user model.User) model.User { return user } - func getGroups() (map[string]string, error) { - groupMap := make(map[string]string) l, err := connLDAP() @@ -260,22 +264,24 @@ func getGroups() (map[string]string, error) { } defer l.Close() + // build search searchRequest := ldap.NewSearchRequest( "dc=c3local", ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, - 0, 0, - false, + 0, 0, false, "(&(objectClass=posixGroup))", []string{"gidNumber", "cn"}, nil, ) + // search sr, err := l.Search(searchRequest) if err != nil { return nil, fmt.Errorf("LDAP search failed: %w", err) } + // arrange result into a map string->string for _, entry := range sr.Entries { gid := entry.GetAttributeValue("gidNumber") cn := entry.GetAttributeValue("cn") @@ -288,7 +294,6 @@ func getGroups() (map[string]string, error) { } func getUIDs() ([]int, error) { - var uidNumbers []int l, err := connLDAP() @@ -297,22 +302,24 @@ func getUIDs() ([]int, error) { } defer l.Close() + // build search searchRequest := ldap.NewSearchRequest( "dc=c3local", ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, - 0, 0, - false, + 0, 0, false, "(&(objectClass=posixAccount))", []string{"uidNumber"}, nil, ) + // search sr, err := l.Search(searchRequest) if err != nil { return nil, fmt.Errorf("LDAP search failed: %w", err) } + // arrange result into a array of integers for _, entry := range sr.Entries { uidStr := entry.GetAttributeValue("uidNumber") if uidStr != "" { @@ -324,6 +331,7 @@ func getUIDs() ([]int, error) { } } + // sort for ease of use :) sort.Ints(uidNumbers) return uidNumbers, nil @@ -333,11 +341,13 @@ func loginExists(users []model.User, login string) bool { return exists(users, func(u model.User) bool { return u.UID == login }) } +// accepts GRRs in the format "GRRXXXXXXXX" aswell as 8 digit number 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 }) } +// generic function for check existence of data types func exists[T any](slice []T, predicate func(T) bool) bool { for _, item := range slice { if predicate(item) { diff --git a/model/user.go b/model/user.go index 901c8a43e909e75d53e8b1bd22114ba38a5defe0..2456d6e6a88ae3c03e211ed714130fdeb1477120 100644 --- a/model/user.go +++ b/model/user.go @@ -41,6 +41,7 @@ func (u *User) ToString() string { u.Nobackup, u.Status, u.Resp, u.Course, u.Expiry) } +// useful for debugging :) func (u *User) FullToString() string { return fmt.Sprintf(`User: DN: %s