wk4: full user CRUD cli
TODO: REST API endpoints
This commit is contained in:
parent
f864d9c84e
commit
bfb687bab8
4 changed files with 282 additions and 111 deletions
197
src/account-manager/controller/account.go
Normal file
197
src/account-manager/controller/account.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
||||
// <username>:<password>:5000:5000::/var/mail/<domain>/<user>/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
|
||||
}
|
||||
|
||||
|
|
@ -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 <username> <password>")
|
||||
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 <username> <password>")
|
||||
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 <username>")
|
||||
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
|
||||
// <username>:<password>:5000:5000::/var/mail/<domain>/<user>/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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// <username>:<password>:5000:5000::/var/mail/<domain>/<user>/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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue