diff --git a/src/account-manager/controller/account.go b/src/account-manager/controller/account.go new file mode 100644 index 0000000..b631521 --- /dev/null +++ b/src/account-manager/controller/account.go @@ -0,0 +1,197 @@ +package controller + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "path" + "strings" + + "jupiter-mail.org/account-manager/model" +) + +var USERDB_PATH = path.Clean("/etc/dovecot/userdb") +const ROOT_UID = 0 +const DOVECOT_GID = 106 + +func OpenUserDB() (*os.File, error) { + err := os.MkdirAll(path.Dir(USERDB_PATH), 0755) + if err != nil { return nil, err } + + file, err := os.OpenFile(USERDB_PATH, os.O_CREATE | os.O_RDWR, 0640) + if err != nil && errors.Is(err, os.ErrExist) { return nil, err } + + if err := file.Chown(ROOT_UID, DOVECOT_GID); err != nil { return nil, err } + + return file, nil +} + +func FetchAccount(username string, domain string) (*model.MailAccount, error) { + accounts, err := FetchAccounts() + if err != nil { + return nil, fmt.Errorf("fetch accounts: %v", err) + } + + for _, account := range accounts { + if account.Username == username && account.Domain == domain { + return account, nil + } + } + + return nil, fmt.Errorf("account does not exist: %s@%s", username, domain) +} + +func IfAccountExists(username string, domain string) (bool, error) { + accounts, err := FetchAccounts() + if err != nil { + return false, fmt.Errorf("fetch accounts: %v", err) + } + + for _, account := range accounts { + if account.Username == username && account.Domain == domain { + return true, nil + } + } + + return false, nil +} + +func HashPassword(username string, domain string, password string) ([]byte, error) { + // doveadm pw -s SHA512-CRYPT -u "$USERNAME" -p "$PASSWORD" + hashCmd := exec.Command( + "doveadm", "pw", + "-s", "SHA512-CRYPT", + "-u", fmt.Sprintf("%s@%s", username, domain), + "-p", password, + ) + out, err := hashCmd.Output() + if err != nil { return nil, err } + return out, nil +} + +func WriteAccounts(accounts []*model.MailAccount) (error) { + newUserDb := strings.Builder{} + for _, account := range accounts { + newUserDb.WriteString(account.UserDbLine()) + } + + if err := os.WriteFile(USERDB_PATH, []byte(newUserDb.String()), 0640); err != nil { + return fmt.Errorf("write userdb: %v", err) + } + + return nil +} + +func CreateAccount(username string, domain string, password string) (*model.MailAccount, error) { + exists, err := IfAccountExists(username, domain) + if err != nil { return nil, fmt.Errorf("failed to check if account exists: %v", err) } + if exists { return nil, fmt.Errorf("account already exists: %s@%s", username, domain) } + + passwordHash, err := HashPassword(username, domain, password) + if err != nil { return nil, fmt.Errorf("hash password: %v", err) } + + account := &model.MailAccount{ + Username: username, + Domain: domain, + PasswordHash: []byte(passwordHash), + } + account.MailDirectory = account.DefaultMailHome() + + accounts, err := FetchAccounts() + if err != nil { return nil, fmt.Errorf("failed to fetch accounts: %v", err) } + accounts = append(accounts, account) + + if err = WriteAccounts(accounts); err != nil { + return nil, fmt.Errorf("write accounts: %v", err) + } + + return account, nil +} + +func FetchAccounts() ([]*model.MailAccount, error) { + file, err := OpenUserDB() + if err != nil { return nil, fmt.Errorf("open userdb: %v", err) } + defer file.Close() + + data, err := io.ReadAll(file) + if err != nil { return nil, fmt.Errorf("read userdb: %v", err) } + + lines := strings.Split(string(data), "\n") + accounts := []*model.MailAccount{} + for i, line := range lines { + if len(line) == 0 { continue } + segments := strings.Split(line, ":") + if len(segments) != 8 { + return nil, fmt.Errorf("malformed data on line %d", i) + } + + // ::5000:5000::/var/mail///home:: + fullUsername := segments[0] + usernameSplits := strings.Split(fullUsername, "@") + if len(usernameSplits) != 2 { + return nil, fmt.Errorf("invalid email format on line %d: %s", i, fullUsername) + } + username := usernameSplits[0] + domain := usernameSplits[1] + + passwordHash := []byte(segments[1]) + mailDir := segments[5] + account := &model.MailAccount{ + Username: username, + Domain: domain, + PasswordHash: passwordHash, + MailDirectory: mailDir, + } + account.MailDirectory = mailDir + accounts = append(accounts, account) + } + + return accounts, nil +} + +func UpdateAccount(username string, domain string, password string) (error) { + accounts, err := FetchAccounts() + if err != nil { return fmt.Errorf("fetch accounts: %v", err) } + + var account *model.MailAccount = nil + for _, acc := range accounts { + if acc.Username == username && acc.Domain == domain { + account = acc + break + } + } + if account == nil { return fmt.Errorf("account does not exist: %s@%s", username, domain) } + + passwordHash, err := HashPassword(username, domain, password) + if err != nil { return fmt.Errorf("hash password: %v", err) } + account.PasswordHash = passwordHash + + WriteAccounts(accounts) + + return nil +} + +func DeleteAccount(username string, domain string) (error) { + accounts, err := FetchAccounts() + if err != nil { return fmt.Errorf("fetch accounts: %v", err) } + + userDbFile, err := OpenUserDB() + if err != nil { return fmt.Errorf("open userdb: %v", err) } + defer userDbFile.Close() + + newAccounts := []*model.MailAccount{} + for _, account := range accounts { + if account.Username != username || account.Domain != domain { + newAccounts = append(newAccounts, account) + } + } + + if err := WriteAccounts(newAccounts); err != nil { + return fmt.Errorf("write accounts: %v", err) + } + + return nil +} + diff --git a/src/account-manager/main.go b/src/account-manager/main.go index fcd3c5c..a4aecb5 100644 --- a/src/account-manager/main.go +++ b/src/account-manager/main.go @@ -2,23 +2,17 @@ package main import ( _ "embed" - "errors" "fmt" - "io" "log" "os" - "os/exec" - "path" "strings" - "jupiter-mail.org/account-manager/model" + "jupiter-mail.org/account-manager/controller" ) //go:embed res/help.txt var helpText string -var USERDB_PATH = path.Clean("/etc/dovecot/userdb") - func main() { if len(os.Args) < 2 { fmt.Fprintln(os.Stderr, helpText) @@ -27,110 +21,74 @@ func main() { switch os.Args[1] { case "create": - account, err := createAccount(os.Args[2], os.Args[3]) + if len(os.Args) < 3 { + log.Fatalf("usage: accounts create ") + os.Exit(1) + } + usernameSplits := strings.Split(os.Args[2], "@") + if len(usernameSplits) != 2 { + log.Fatalf("Invalid email format: %s", os.Args[2]) + os.Exit(1) + } + username := usernameSplits[0] + domain := usernameSplits[1] + account, err := controller.CreateAccount(username, domain, os.Args[3]) if err != nil { log.Fatalf("Failed to create account: %v", err) os.Exit(1) } log.Printf("Account \"%s\" created successfully.", account.FullUsername()) case "list": - log.Fatalf("list: not implemented") - os.Exit(1) + accounts, err := controller.FetchAccounts() + if err != nil { + log.Fatalf("Failed to fetch accounts: %v", err) + os.Exit(1) + } + for _, account := range accounts { + fmt.Printf( + "%s:\n\thome: %s\n", + account.FullUsername(), + account.MailDirectory, + ) + } case "update": - log.Fatalf("update: not implemented") - os.Exit(1) + if len(os.Args) < 3 { + log.Fatalf("usage: accounts update ") + os.Exit(1) + } + usernameSplits := strings.Split(os.Args[2], "@") + if len(usernameSplits) != 2 { + log.Fatalf("Invalid email format: %s", os.Args[2]) + os.Exit(1) + } + username := usernameSplits[0] + domain := usernameSplits[1] + err := controller.UpdateAccount(username, domain, os.Args[3]) + if err != nil { + log.Fatalf("Failed to update account: %s", os.Args[2]) + os.Exit(1) + } + log.Printf("%s password updated successfully.", os.Args[2]) case "delete": - log.Fatalf("delete: not implemented") - os.Exit(1) + if len(os.Args) < 2 { + log.Fatalf("usage: accounts delete ") + os.Exit(1) + } + usernameSplits := strings.Split(os.Args[2], "@") + if len(usernameSplits) != 2 { + log.Fatalf("Invalid email format: %s", os.Args[2]) + os.Exit(1) + } + username := usernameSplits[0] + domain := usernameSplits[1] + err := controller.DeleteAccount(username, domain) + if err != nil { + log.Fatalf("Failed to delete account: %s", os.Args[2]) + os.Exit(1) + } + log.Printf("%s deleted successfully.", os.Args[2]) default: log.Fatalf("Unrecognised action \"%s\"", os.Args[1]) os.Exit(1) } } - -func openUserDB() (*os.File, error) { - err := os.MkdirAll(path.Dir(USERDB_PATH), 0755) - if err != nil { return nil, err } - - file, err := os.OpenFile(USERDB_PATH, os.O_CREATE | os.O_RDWR, 0640) - if err != nil && errors.Is(err, os.ErrExist) { return nil, err } - - return file, nil -} - -func getMailHome(user string, domain string) string { - return fmt.Sprintf("/var/mail/%s/%s/home", domain, user) -} - -func checkAccountExists(username string) (bool, error) { - file, err := openUserDB() - if err != nil { return false, fmt.Errorf("open userdb: %v", err) } - - data, err := io.ReadAll(file) - if err != nil { return false, fmt.Errorf("read userdb: %v", err) } - file.Close() - - lines := strings.Split(string(data), "\n") - for _, line := range lines { - if strings.HasPrefix(line, username) { return true, nil } - } - - return false, nil -} - -func createAccount(fullUsername string, password string) (*model.MailAccount, error) { - exists, err := checkAccountExists(fullUsername) - if err != nil { return nil, fmt.Errorf("failed to check if account exists: %v", err) } - if exists { return nil, fmt.Errorf("account already exists: %s", fullUsername) } - - usernameSplits := strings.Split(fullUsername, "@") - if len(usernameSplits) != 2 { - return nil, fmt.Errorf("invalid email format: %s", fullUsername) - } - username := usernameSplits[0] - domain := usernameSplits[1] - - passwordHash, err := hashPassword(username, password) - if err != nil { return nil, fmt.Errorf("hash password: %v", err) } - - account := model.NewMailAccount(username, domain, []byte(passwordHash)) - - uid := 5000; gid := 5000 - // ::5000:5000::/var/mail///home:: - userDbLine := fmt.Sprintf( - "%s:%s:%d:%d::%s::\n", - account.FullUsername(), - account.PasswordHash, - uid, gid, - getMailHome(account.Username, account.Domain), - ) - - file, err := openUserDB() - if err != nil { return nil, fmt.Errorf("open userdb: %v", err) } - buf := make([]byte, 1024) - for n, err := file.Read(buf); n > 0; { - if err != nil { - return nil, fmt.Errorf("read userdb: %v", err) - } - } - _, err = file.Write([]byte(userDbLine)) - if err != nil { return nil, err } - - err = file.Close() - if err != nil { return nil, err } - - return &account, nil -} - -func hashPassword(username string, password string) (string, error) { - // doveadm pw -s SHA512-CRYPT -u "$USERNAME" -p "$PASSWORD" - hashCmd := exec.Command( - "doveadm", "pw", - "-s", "SHA512-CRYPT", - "-u", username, - "-p", password, - ) - out, err := hashCmd.Output() - if err != nil { return "", err } - return string(out), nil -} diff --git a/src/account-manager/model/account.go b/src/account-manager/model/account.go index c7a68d3..2cc3742 100644 --- a/src/account-manager/model/account.go +++ b/src/account-manager/model/account.go @@ -1,23 +1,39 @@ package model -import "fmt" +import ( + "fmt" + "strings" +) type ( MailAccount struct { Username string `json:"username"` Domain string `json:"domain"` PasswordHash []byte `json:"password"` + MailDirectory string `json:"mail_dir"` } ) -func NewMailAccount(username string, domain string, passwordHash []byte) MailAccount { - return MailAccount{ - Username: username, - Domain: domain, - PasswordHash: passwordHash, - } -} - func (account *MailAccount) FullUsername() string { return fmt.Sprintf("%s@%s", account.Username, account.Domain) } + +func (account *MailAccount) UserDbLine() string { + uid := 5000; gid := 5000 + // ::5000:5000::/var/mail///home:: + return fmt.Sprintf( + "%s:%s:%d:%d::%s::\n", + account.FullUsername(), + strings.Trim(string(account.PasswordHash), " \n"), + uid, gid, + account.DefaultMailHome(), + ) +} + +func (account *MailAccount) DefaultMailHome() string { + return fmt.Sprintf( + "/var/mail/%s/%s/home", + account.Domain, + account.Username, + ) +} diff --git a/target/scripts/start-services.sh b/target/scripts/start-services.sh index 2ccf1b9..54416b8 100644 --- a/target/scripts/start-services.sh +++ b/target/scripts/start-services.sh @@ -23,6 +23,6 @@ log "info" "Starting daemons..." start_daemon saslauthd start_daemon postgresql start_daemon dovecot -start_daemon postfix +# start_daemon postfix exec tail "${LOGFILE}"