Skip to content
Snippets Groups Projects
Select Git revision
  • main default protected
  • refactor/folder-structure
2 results

create.go

Blame
  • create.go 17.14 KiB
    package user
    
    import (
    	"os"
    	"fmt"
    	"time"
    	"regexp"
    	"os/exec"
    	"strconv"
    	"strings"
    	"path/filepath"
    
    	"github.com/spf13/cobra"
    	"github.com/go-ldap/ldap/v3"
    	"gitlab.c3sl.ufpr.br/tss24/useradm/model"
    )
    
    const (
        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
    )
    
    var CreateUserCmd = &cobra.Command{
    	Use:   "create",
    	Short: "Create new user",
    	RunE:  createUserFunc,
    }
    
    func init() {
    	// 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")
    	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
    	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
        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()) // for debug
    	//fmt.Printf("%v\n	Passwd:  %v\n\n", usr.ToString(), usr.Password)
        confirmationPrompt(confirm, "creation")
    
        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("User created!")
        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
    }
    
    // creates and validates user inputs into the User model
    func createNewUserModel(cmd *cobra.Command) (model.User, bool, error) {
        var u model.User
        var opts model.Opts
        users, err := getUsers()
        if err != nil {
            return u, false, err
        }
    
        groups, err := getGroups()
        if err != nil {
            return u, false, err
        }
    
        err = opts.RetrieveOpts(cmd)
        if err != nil {
            return u, false, err
        }
    
        if err := validateInputs(opts)
        err != nil {
    	    return u, false, err
        }
    
        if opts.Status == "Blocked" {
            opts.Shell = "/bin/false"
        }
    
        if opts.Ltype == "ini" && opts.GRR == "_" {
            return u, false, fmt.Errorf("GRR is required for \"ini\" login type")
        }
    
        if opts.UID == "" {
            opts.UID, err = genUniqueUID(opts.Name, opts.GRR, opts.Ltype, users)
            if err != nil {
                return u, false, err
            }
        }
    
        opts.Homedir, err = genDirPath("/home", opts.GID, opts.UID, opts.Homedir)
        if err != nil {
            return u, false, err
        }
    
        opts.Nobkp, err = genDirPath("/nobackup", opts.GID, opts.UID, opts.Nobkp)
        if err != nil {
            return u, false, err
        }
    
        opts.Webdir, err = genDirPath("/home/html/inf", "", opts.UID, opts.Webdir)
        if err != nil {
            return u, false, err
        }
    
        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 {
            return u, false, fmt.Errorf("failed to generate new UIDNumber for user: %v", err)
        }
        u.UIDNumber = newUIDNumber
    
        // 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, opts.Confirm, nil
    }
    
    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 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
    }
    
    type LoginType int
    
    const (
    	Initials LoginType = iota
    	FirstName
    	LastName
    )
    
    func genLogin(name string, grr string, ltype LoginType, variance int) string {
    	parts := formatName(name)
    	if len(parts) == 0 {
    		return "_"
    	}
    
    	part_prefix_len := make([]int, len(parts))
    	if ltype == Initials {
    		// a primeira letra de cada parte deve aparecer
    		for i := 0; i < len(parts); i++ {
    			part_prefix_len[i] = 1
    		}
    	} else if ltype == FirstName {
    		// a primeira parte inteira deve aparecer
    		part_prefix_len[0] = len(parts[0])
    	} else {
    		// a primeira letra de cada parte deve aparecer
    		for i := 0; i < len(parts); i++ {
    			part_prefix_len[i] = 1
    		}
    		// assim com a parte final do nome
    		part_prefix_len[len(parts)-1] = len(parts[len(parts)-1])
    	}
    	part_prefix_ix := 0
    	for i := 0; i < variance; i++ {
    		ok := false
    		for k := 0; k < len(parts) && !ok; k++ {
    			if part_prefix_len[part_prefix_ix] < len(parts[part_prefix_ix]) {
    				part_prefix_len[part_prefix_ix]++
    				ok = true
    			}
    			part_prefix_ix = (part_prefix_ix + 1) % len(parts)
    		}
    		if !ok {
    			// acabou o que fazer, vamos abandonar porque não vai mudar mais nada
    			break
    		}
    	}
    
    	login := ""
    	for i := 0; i < len(parts); i++ {
    		login += parts[i][:part_prefix_len[i]]
    	}
    	if login == "" {
    		return "_"
    	}
    
    	if ltype == Initials {
    		login += grr[2:4]
    	}
    	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
    }
    
    func genUniqueUID(name, grr string, ltypeString string, users []model.User) (string, error) {
        var uid string
        used, variance := true, 0
        for used {
    		var ltype LoginType
    		if ltypeString == "ini" {
    			ltype = Initials
    		} else if ltypeString == "first" {
    			ltype = FirstName
    		} else {
    			ltype = LastName
    		}
            uid = genLogin(name, grr, ltype, variance)
    
            // already taken or alias for it exists :(
            used = loginExists(users, uid) || mailAliasExists(uid)
    
            variance++
            if variance > MAX_VARIANCE {
                return "", fmt.Errorf("Could't generate login automatically, please inform the desired login\n")
            }
        }
        return uid, nil
    }
    
    // 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
    } 
    
    // finds next available uidNumber (MEX from uid group)
    func getNewUIDNumber() (string, error) {
        uids, err := getUIDs()
        if err != nil {
            return "", err
        }
    
        candidate := MIN_UID
        for _, uid := range uids {
            if uid == candidate { // check if taken
                candidate++
            } else if uid > candidate { // found a gap
                break
            }
        }
    
        if candidate > MAX_UID {
            return "", fmt.Errorf("No more available UID numbers")
        }
        return strconv.Itoa(candidate), nil
    }
    
    // generates a LDAP request and adds the user to LDAP
    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
    }
    
    // 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))
    
    	output, err := cmd.CombinedOutput()
    	if err != nil {
            return fmt.Errorf("Failed to add Kerberos principal: %v\nOutput: %s", err, output)
    	}
    
    	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))
    
        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 creating dirs, cleaning up...")
                _ = os.RemoveAll(u.Nobackup)
                _ = os.RemoveAll(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 createHome(u model.User, homeDir string) error {
    
        perm := DEF_PERMISSION
        if u.Status == "Blocked" {
            perm = BLK_PERMISSION
        }
    
    	// create directory
    	cmd := exec.Command("mkdir", "-p", homeDir)
        cmd.Stdout = nil
        cmd.Stderr = nil
    	if err := cmd.Run(); err != nil {
    		return fmt.Errorf("Failed to create home directory: %w", err)
    	}
    
    	// 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
        cmd = exec.Command("chmod", perm, homeDir)
    	if err := cmd.Run(); err != nil {
    		return fmt.Errorf("Failed to set permissions: %w", err)
    	}
    
    	// 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)
    	}
    
        return nil
    }
    
    func createWeb(u model.User) error {
        success := false
    
        perm := DEF_PERMISSION
        if u.Status == "Blocked" {
            perm = BLK_PERMISSION
        }
    
        defer func() {
            if !success {
                _ = os.RemoveAll(u.Webdir)
            }
        }()
    
    	// create directory
    	cmd := exec.Command("mkdir", "-p", u.Webdir)
        cmd.Stdout = nil
        cmd.Stderr = nil
    	if err :=  cmd.Run(); err != nil {
    		return fmt.Errorf("Failed to create web directory: %w", err)
    	}
    
    	// 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
    	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
        cmd = exec.Command("chmod", perm, u.Webdir)
    	if err := cmd.Run(); err != nil {
    		return fmt.Errorf("Failed to set permissions: %w", err)
    	}
    
    	// 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)
    	}
    
        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 validateLtype(ltype string) error {
    	if ltype != "ini" && ltype != "first" && ltype != "last" {
    		err := fmt.Errorf("Login type can only be \"ini\", \"first\" or \"last\"")
    		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 model.Opts) 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 }
    
    	err = validateLtype(opts.Ltype)
    	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 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
    }