wk4: full user CRUD cli

TODO: REST API endpoints
This commit is contained in:
ari melody 2026-02-12 10:40:34 +00:00
parent f864d9c84e
commit bfb687bab8
Signed by: ari
GPG key ID: CF99829C92678188
4 changed files with 282 additions and 111 deletions

View 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
}

View file

@ -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")
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")
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")
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
}

View file

@ -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,
)
}

View file

@ -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}"