Select Git revision
-
fmkiotheka authoredfmkiotheka authored
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
}