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 (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
|
||||||
"path"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"jupiter-mail.org/account-manager/model"
|
"jupiter-mail.org/account-manager/controller"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed res/help.txt
|
//go:embed res/help.txt
|
||||||
var helpText string
|
var helpText string
|
||||||
|
|
||||||
var USERDB_PATH = path.Clean("/etc/dovecot/userdb")
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
fmt.Fprintln(os.Stderr, helpText)
|
fmt.Fprintln(os.Stderr, helpText)
|
||||||
|
|
@ -27,110 +21,74 @@ func main() {
|
||||||
|
|
||||||
switch os.Args[1] {
|
switch os.Args[1] {
|
||||||
case "create":
|
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 {
|
if err != nil {
|
||||||
log.Fatalf("Failed to create account: %v", err)
|
log.Fatalf("Failed to create account: %v", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
log.Printf("Account \"%s\" created successfully.", account.FullUsername())
|
log.Printf("Account \"%s\" created successfully.", account.FullUsername())
|
||||||
case "list":
|
case "list":
|
||||||
log.Fatalf("list: not implemented")
|
accounts, err := controller.FetchAccounts()
|
||||||
os.Exit(1)
|
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":
|
case "update":
|
||||||
log.Fatalf("update: not implemented")
|
if len(os.Args) < 3 {
|
||||||
os.Exit(1)
|
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":
|
case "delete":
|
||||||
log.Fatalf("delete: not implemented")
|
if len(os.Args) < 2 {
|
||||||
os.Exit(1)
|
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:
|
default:
|
||||||
log.Fatalf("Unrecognised action \"%s\"", os.Args[1])
|
log.Fatalf("Unrecognised action \"%s\"", os.Args[1])
|
||||||
os.Exit(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
|
package model
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
MailAccount struct {
|
MailAccount struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
PasswordHash []byte `json:"password"`
|
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 {
|
func (account *MailAccount) FullUsername() string {
|
||||||
return fmt.Sprintf("%s@%s", account.Username, account.Domain)
|
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 saslauthd
|
||||||
start_daemon postgresql
|
start_daemon postgresql
|
||||||
start_daemon dovecot
|
start_daemon dovecot
|
||||||
start_daemon postfix
|
# start_daemon postfix
|
||||||
|
|
||||||
exec tail "${LOGFILE}"
|
exec tail "${LOGFILE}"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue