mirror of
https://github.com/foxcpp/maddy.git
synced 2025-04-04 21:47:40 +03:00
Fully separate authentication from IMAP access
Now imapsql module does not handle authentication. (it was not doing it so well anyway) sql_table module was introduced and used in the default configuration as a replacement for functionality that was implemented by imapsql before. Parts of maddyctl code were rewritten to make it work transparently with any IMAP backend or credentials store. Closes #212.
This commit is contained in:
parent
609a8fd235
commit
e19d21dfcb
29 changed files with 867 additions and 473 deletions
|
@ -5,6 +5,7 @@ import (
|
|||
"fmt"
|
||||
|
||||
appendlimit "github.com/emersion/go-imap-appendlimit"
|
||||
"github.com/foxcpp/maddy/internal/module"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
|
@ -20,19 +21,19 @@ type AppendLimitUser interface {
|
|||
SetMessageLimit(val *uint32) error
|
||||
}
|
||||
|
||||
func usersAppendlimit(be Storage, ctx *cli.Context) error {
|
||||
func imapAcctAppendlimit(be module.Storage, ctx *cli.Context) error {
|
||||
username := ctx.Args().First()
|
||||
if username == "" {
|
||||
return errors.New("Error: USERNAME is required")
|
||||
}
|
||||
|
||||
u, err := be.GetUser(username)
|
||||
u, err := be.GetIMAPAcct(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
userAL, ok := u.(AppendLimitUser)
|
||||
if !ok {
|
||||
return errors.New("Error: Storage does not support per-user append limit")
|
||||
return errors.New("Error: module.Storage does not support per-user append limit")
|
||||
}
|
||||
|
||||
if ctx.IsSet("value") {
|
||||
|
|
|
@ -12,6 +12,7 @@ import (
|
|||
"github.com/emersion/go-imap"
|
||||
imapsql "github.com/foxcpp/go-imap-sql"
|
||||
"github.com/foxcpp/maddy/cmd/maddyctl/clitools"
|
||||
"github.com/foxcpp/maddy/internal/module"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
|
@ -27,13 +28,13 @@ func FormatAddressList(addrs []*imap.Address) string {
|
|||
return strings.Join(res, ", ")
|
||||
}
|
||||
|
||||
func mboxesList(be Storage, ctx *cli.Context) error {
|
||||
func mboxesList(be module.Storage, ctx *cli.Context) error {
|
||||
username := ctx.Args().First()
|
||||
if username == "" {
|
||||
return errors.New("Error: USERNAME is required")
|
||||
}
|
||||
|
||||
u, err := be.GetUser(username)
|
||||
u, err := be.GetIMAPAcct(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -63,7 +64,7 @@ func mboxesList(be Storage, ctx *cli.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func mboxesCreate(be Storage, ctx *cli.Context) error {
|
||||
func mboxesCreate(be module.Storage, ctx *cli.Context) error {
|
||||
username := ctx.Args().First()
|
||||
if username == "" {
|
||||
return errors.New("Error: USERNAME is required")
|
||||
|
@ -73,11 +74,12 @@ func mboxesCreate(be Storage, ctx *cli.Context) error {
|
|||
return errors.New("Error: NAME is required")
|
||||
}
|
||||
|
||||
u, err := be.GetUser(username)
|
||||
u, err := be.GetIMAPAcct(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: Generalize.
|
||||
if ctx.IsSet("special") {
|
||||
attr := "\\" + strings.Title(ctx.String("special"))
|
||||
return u.(*imapsql.User).CreateMailboxSpecial(name, attr)
|
||||
|
@ -86,7 +88,7 @@ func mboxesCreate(be Storage, ctx *cli.Context) error {
|
|||
return u.CreateMailbox(name)
|
||||
}
|
||||
|
||||
func mboxesRemove(be Storage, ctx *cli.Context) error {
|
||||
func mboxesRemove(be module.Storage, ctx *cli.Context) error {
|
||||
username := ctx.Args().First()
|
||||
if username == "" {
|
||||
return errors.New("Error: USERNAME is required")
|
||||
|
@ -96,7 +98,7 @@ func mboxesRemove(be Storage, ctx *cli.Context) error {
|
|||
return errors.New("Error: NAME is required")
|
||||
}
|
||||
|
||||
u, err := be.GetUser(username)
|
||||
u, err := be.GetIMAPAcct(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -128,7 +130,7 @@ func mboxesRemove(be Storage, ctx *cli.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func mboxesRename(be Storage, ctx *cli.Context) error {
|
||||
func mboxesRename(be module.Storage, ctx *cli.Context) error {
|
||||
username := ctx.Args().First()
|
||||
if username == "" {
|
||||
return errors.New("Error: USERNAME is required")
|
||||
|
@ -142,7 +144,7 @@ func mboxesRename(be Storage, ctx *cli.Context) error {
|
|||
return errors.New("Error: NEWNAME is required")
|
||||
}
|
||||
|
||||
u, err := be.GetUser(username)
|
||||
u, err := be.GetIMAPAcct(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -150,7 +152,7 @@ func mboxesRename(be Storage, ctx *cli.Context) error {
|
|||
return u.RenameMailbox(oldName, newName)
|
||||
}
|
||||
|
||||
func msgsAdd(be Storage, ctx *cli.Context) error {
|
||||
func msgsAdd(be module.Storage, ctx *cli.Context) error {
|
||||
username := ctx.Args().First()
|
||||
if username == "" {
|
||||
return errors.New("Error: USERNAME is required")
|
||||
|
@ -160,7 +162,7 @@ func msgsAdd(be Storage, ctx *cli.Context) error {
|
|||
return errors.New("Error: MAILBOX is required")
|
||||
}
|
||||
|
||||
u, err := be.GetUser(username)
|
||||
u, err := be.GetIMAPAcct(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -203,7 +205,7 @@ func msgsAdd(be Storage, ctx *cli.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func msgsRemove(be Storage, ctx *cli.Context) error {
|
||||
func msgsRemove(be module.Storage, ctx *cli.Context) error {
|
||||
username := ctx.Args().First()
|
||||
if username == "" {
|
||||
return errors.New("Error: USERNAME is required")
|
||||
|
@ -222,7 +224,7 @@ func msgsRemove(be Storage, ctx *cli.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
u, err := be.GetUser(username)
|
||||
u, err := be.GetIMAPAcct(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -246,7 +248,7 @@ func msgsRemove(be Storage, ctx *cli.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func msgsCopy(be Storage, ctx *cli.Context) error {
|
||||
func msgsCopy(be module.Storage, ctx *cli.Context) error {
|
||||
username := ctx.Args().First()
|
||||
if username == "" {
|
||||
return errors.New("Error: USERNAME is required")
|
||||
|
@ -269,7 +271,7 @@ func msgsCopy(be Storage, ctx *cli.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
u, err := be.GetUser(username)
|
||||
u, err := be.GetIMAPAcct(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -282,7 +284,7 @@ func msgsCopy(be Storage, ctx *cli.Context) error {
|
|||
return srcMbox.CopyMessages(ctx.Bool("uid"), seq, tgtName)
|
||||
}
|
||||
|
||||
func msgsMove(be Storage, ctx *cli.Context) error {
|
||||
func msgsMove(be module.Storage, ctx *cli.Context) error {
|
||||
if ctx.Bool("y,yes") || !clitools.Confirmation("Currently, it is unsafe to remove messages from mailboxes used by connected clients, continue?", false) {
|
||||
return errors.New("Cancelled")
|
||||
}
|
||||
|
@ -309,7 +311,7 @@ func msgsMove(be Storage, ctx *cli.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
u, err := be.GetUser(username)
|
||||
u, err := be.GetIMAPAcct(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -324,7 +326,7 @@ func msgsMove(be Storage, ctx *cli.Context) error {
|
|||
return moveMbox.MoveMessages(ctx.Bool("uid"), seq, tgtName)
|
||||
}
|
||||
|
||||
func msgsList(be Storage, ctx *cli.Context) error {
|
||||
func msgsList(be module.Storage, ctx *cli.Context) error {
|
||||
username := ctx.Args().First()
|
||||
if username == "" {
|
||||
return errors.New("Error: USERNAME is required")
|
||||
|
@ -343,7 +345,7 @@ func msgsList(be Storage, ctx *cli.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
u, err := be.GetUser(username)
|
||||
u, err := be.GetIMAPAcct(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -400,7 +402,7 @@ func msgsList(be Storage, ctx *cli.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
func msgsDump(be Storage, ctx *cli.Context) error {
|
||||
func msgsDump(be module.Storage, ctx *cli.Context) error {
|
||||
username := ctx.Args().First()
|
||||
if username == "" {
|
||||
return errors.New("Error: USERNAME is required")
|
||||
|
@ -419,7 +421,7 @@ func msgsDump(be Storage, ctx *cli.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
u, err := be.GetUser(username)
|
||||
u, err := be.GetIMAPAcct(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -444,7 +446,7 @@ func msgsDump(be Storage, ctx *cli.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
func msgsFlags(be Storage, ctx *cli.Context) error {
|
||||
func msgsFlags(be module.Storage, ctx *cli.Context) error {
|
||||
username := ctx.Args().First()
|
||||
if username == "" {
|
||||
return errors.New("Error: USERNAME is required")
|
||||
|
@ -463,7 +465,7 @@ func msgsFlags(be Storage, ctx *cli.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
u, err := be.GetUser(username)
|
||||
u, err := be.GetIMAPAcct(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
66
cmd/maddyctl/imapacct.go
Normal file
66
cmd/maddyctl/imapacct.go
Normal file
|
@ -0,0 +1,66 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/foxcpp/maddy/cmd/maddyctl/clitools"
|
||||
"github.com/foxcpp/maddy/internal/module"
|
||||
"github.com/urfave/cli"
|
||||
)
|
||||
|
||||
func imapAcctList(be module.Storage, ctx *cli.Context) error {
|
||||
mbe, ok := be.(module.ManageableStorage)
|
||||
if !ok {
|
||||
return errors.New("Error: storage backend does not support accounts management using maddyctl")
|
||||
}
|
||||
|
||||
list, err := mbe.ListAccts()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(list) == 0 && !ctx.GlobalBool("quiet") {
|
||||
fmt.Fprintln(os.Stderr, "No users.")
|
||||
}
|
||||
|
||||
for _, user := range list {
|
||||
fmt.Println(user)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func imapAcctCreate(be module.Storage, ctx *cli.Context) error {
|
||||
mbe, ok := be.(module.ManageableStorage)
|
||||
if !ok {
|
||||
return errors.New("Error: storage backend does not support accounts management using maddyctl")
|
||||
}
|
||||
|
||||
username := ctx.Args().First()
|
||||
if username == "" {
|
||||
return errors.New("Error: USERNAME is required")
|
||||
}
|
||||
|
||||
return mbe.CreateAcct(username)
|
||||
}
|
||||
|
||||
func imapAcctRemove(be module.Storage, ctx *cli.Context) error {
|
||||
mbe, ok := be.(module.ManageableStorage)
|
||||
if !ok {
|
||||
return errors.New("Error: storage backend does not support accounts management using maddyctl")
|
||||
}
|
||||
|
||||
username := ctx.Args().First()
|
||||
if username == "" {
|
||||
return errors.New("Error: USERNAME is required")
|
||||
}
|
||||
|
||||
if !ctx.Bool("yes") {
|
||||
if !clitools.Confirmation("Are you sure you want to delete this user account?", false) {
|
||||
return errors.New("Cancelled")
|
||||
}
|
||||
}
|
||||
|
||||
return mbe.DeleteAcct(username)
|
||||
}
|
|
@ -3,28 +3,24 @@ package main
|
|||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/emersion/go-imap/backend"
|
||||
"github.com/foxcpp/maddy"
|
||||
"github.com/foxcpp/maddy/internal/config"
|
||||
"github.com/foxcpp/maddy/internal/hooks"
|
||||
"github.com/foxcpp/maddy/internal/module"
|
||||
"github.com/foxcpp/maddy/internal/updatepipe"
|
||||
parser "github.com/foxcpp/maddy/pkg/cfgparser"
|
||||
"github.com/urfave/cli"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type UserDB interface {
|
||||
ListUsers() ([]string, error)
|
||||
CreateUser(username, password string) error
|
||||
CreateUserNoPass(username string) error
|
||||
DeleteUser(username string) error
|
||||
SetUserPassword(username, newPassword string) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
type Storage interface {
|
||||
GetUser(username string) (backend.User, error)
|
||||
Close() error
|
||||
func closeIfNeeded(i interface{}) {
|
||||
if c, ok := i.(io.Closer); ok {
|
||||
c.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
@ -44,12 +40,12 @@ func main() {
|
|||
|
||||
app.Commands = []cli.Command{
|
||||
{
|
||||
Name: "users",
|
||||
Usage: "User accounts management",
|
||||
Name: "creds",
|
||||
Usage: "Local credentials management",
|
||||
Subcommands: []cli.Command{
|
||||
{
|
||||
Name: "list",
|
||||
Usage: "List created user accounts",
|
||||
Usage: "List created credentials",
|
||||
Flags: []cli.Flag{
|
||||
cli.StringFlag{
|
||||
Name: "cfg-block",
|
||||
|
@ -63,7 +59,7 @@ func main() {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer be.Close()
|
||||
defer closeIfNeeded(be)
|
||||
return usersList(be, ctx)
|
||||
},
|
||||
},
|
||||
|
@ -103,7 +99,7 @@ func main() {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer be.Close()
|
||||
defer closeIfNeeded(be)
|
||||
return usersCreate(be, ctx)
|
||||
},
|
||||
},
|
||||
|
@ -128,7 +124,7 @@ func main() {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer be.Close()
|
||||
defer closeIfNeeded(be)
|
||||
return usersRemove(be, ctx)
|
||||
},
|
||||
},
|
||||
|
@ -148,33 +144,91 @@ func main() {
|
|||
Name: "password,p",
|
||||
Usage: "Use `PASSWORD` instead of reading password from stdin.\n\t\tWARNING: Provided only for debugging convenience. Don't leave your passwords in shell history!",
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "null,n",
|
||||
Usage: "Set password to null",
|
||||
},
|
||||
cli.StringFlag{
|
||||
Name: "hash",
|
||||
Usage: "Use specified hash algorithm for password. Supported values vary depending on storage backend.",
|
||||
Value: "",
|
||||
},
|
||||
cli.IntFlag{
|
||||
Name: "bcrypt-cost",
|
||||
Usage: "Specify bcrypt cost value",
|
||||
Value: bcrypt.DefaultCost,
|
||||
},
|
||||
},
|
||||
Action: func(ctx *cli.Context) error {
|
||||
be, err := openUserDB(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer be.Close()
|
||||
defer closeIfNeeded(be)
|
||||
return usersPassword(be, ctx)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "imap-acct",
|
||||
Usage: "IMAP storage accounts management",
|
||||
Subcommands: []cli.Command{
|
||||
{
|
||||
Name: "imap-appendlimit",
|
||||
Usage: "Query or set user's APPENDLIMIT value",
|
||||
Name: "list",
|
||||
Usage: "List storage accounts",
|
||||
Flags: []cli.Flag{
|
||||
cli.StringFlag{
|
||||
Name: "cfg-block",
|
||||
Usage: "Module configuration block to use",
|
||||
EnvVar: "MADDY_CFGBLOCK",
|
||||
Value: "local_mailboxes",
|
||||
},
|
||||
},
|
||||
Action: func(ctx *cli.Context) error {
|
||||
be, err := openStorage(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeIfNeeded(be)
|
||||
return imapAcctList(be, ctx)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "create",
|
||||
Usage: "Create IMAP storage account",
|
||||
ArgsUsage: "USERNAME",
|
||||
Flags: []cli.Flag{
|
||||
cli.StringFlag{
|
||||
Name: "cfg-block",
|
||||
Usage: "Module configuration block to use",
|
||||
EnvVar: "MADDY_CFGBLOCK",
|
||||
Value: "local_authdb",
|
||||
},
|
||||
},
|
||||
Action: func(ctx *cli.Context) error {
|
||||
be, err := openStorage(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeIfNeeded(be)
|
||||
return imapAcctCreate(be, ctx)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "remove",
|
||||
Usage: "Delete IMAP storage account",
|
||||
ArgsUsage: "USERNAME",
|
||||
Flags: []cli.Flag{
|
||||
cli.StringFlag{
|
||||
Name: "cfg-block",
|
||||
Usage: "Module configuration block to use",
|
||||
EnvVar: "MADDY_CFGBLOCK",
|
||||
Value: "local_authdb",
|
||||
},
|
||||
cli.BoolFlag{
|
||||
Name: "yes,y",
|
||||
Usage: "Don't ask for confirmation",
|
||||
},
|
||||
},
|
||||
Action: func(ctx *cli.Context) error {
|
||||
be, err := openStorage(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeIfNeeded(be)
|
||||
return imapAcctRemove(be, ctx)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "appendlimit",
|
||||
Usage: "Query or set accounts's APPENDLIMIT value",
|
||||
ArgsUsage: "USERNAME",
|
||||
Flags: []cli.Flag{
|
||||
cli.StringFlag{
|
||||
|
@ -193,8 +247,8 @@ func main() {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer be.Close()
|
||||
return usersAppendlimit(be, ctx)
|
||||
defer closeIfNeeded(be)
|
||||
return imapAcctAppendlimit(be, ctx)
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -224,7 +278,7 @@ func main() {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer be.Close()
|
||||
defer closeIfNeeded(be)
|
||||
return mboxesList(be, ctx)
|
||||
},
|
||||
},
|
||||
|
@ -249,7 +303,7 @@ func main() {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer be.Close()
|
||||
defer closeIfNeeded(be)
|
||||
return mboxesCreate(be, ctx)
|
||||
},
|
||||
},
|
||||
|
@ -275,7 +329,7 @@ func main() {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer be.Close()
|
||||
defer closeIfNeeded(be)
|
||||
return mboxesRemove(be, ctx)
|
||||
},
|
||||
},
|
||||
|
@ -297,7 +351,7 @@ func main() {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer be.Close()
|
||||
defer closeIfNeeded(be)
|
||||
return mboxesRename(be, ctx)
|
||||
},
|
||||
},
|
||||
|
@ -333,7 +387,7 @@ func main() {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer be.Close()
|
||||
defer closeIfNeeded(be)
|
||||
return msgsAdd(be, ctx)
|
||||
},
|
||||
},
|
||||
|
@ -359,7 +413,7 @@ func main() {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer be.Close()
|
||||
defer closeIfNeeded(be)
|
||||
return msgsFlags(be, ctx)
|
||||
},
|
||||
},
|
||||
|
@ -385,7 +439,7 @@ func main() {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer be.Close()
|
||||
defer closeIfNeeded(be)
|
||||
return msgsFlags(be, ctx)
|
||||
},
|
||||
},
|
||||
|
@ -411,7 +465,7 @@ func main() {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer be.Close()
|
||||
defer closeIfNeeded(be)
|
||||
return msgsFlags(be, ctx)
|
||||
},
|
||||
},
|
||||
|
@ -440,7 +494,7 @@ func main() {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer be.Close()
|
||||
defer closeIfNeeded(be)
|
||||
return msgsRemove(be, ctx)
|
||||
},
|
||||
},
|
||||
|
@ -466,7 +520,7 @@ func main() {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer be.Close()
|
||||
defer closeIfNeeded(be)
|
||||
return msgsCopy(be, ctx)
|
||||
},
|
||||
},
|
||||
|
@ -496,7 +550,7 @@ func main() {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer be.Close()
|
||||
defer closeIfNeeded(be)
|
||||
return msgsMove(be, ctx)
|
||||
},
|
||||
},
|
||||
|
@ -526,7 +580,7 @@ func main() {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer be.Close()
|
||||
defer closeIfNeeded(be)
|
||||
return msgsList(be, ctx)
|
||||
},
|
||||
},
|
||||
|
@ -552,7 +606,7 @@ func main() {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer be.Close()
|
||||
defer closeIfNeeded(be)
|
||||
return msgsDump(be, ctx)
|
||||
},
|
||||
},
|
||||
|
@ -601,34 +655,70 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
func openStorage(ctx *cli.Context) (Storage, error) {
|
||||
func getCfgBlockModule(ctx *cli.Context) (map[string]interface{}, *maddy.ModInfo, error) {
|
||||
cfgPath := ctx.GlobalString("config")
|
||||
if cfgPath == "" {
|
||||
return nil, errors.New("Error: config is required")
|
||||
return nil, nil, errors.New("Error: config is required")
|
||||
}
|
||||
cfgFile, err := os.Open(cfgPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("Error: failed to open config: %w", err)
|
||||
}
|
||||
defer cfgFile.Close()
|
||||
cfgNodes, err := parser.Read(cfgFile, cfgFile.Name())
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("Error: failed to parse config: %w", err)
|
||||
}
|
||||
|
||||
globals, cfgNodes, err := maddy.ReadGlobals(cfgNodes)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if err := maddy.InitDirs(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
_, mods, err := maddy.RegisterModules(globals, cfgNodes)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer hooks.RunHooks(hooks.EventShutdown)
|
||||
|
||||
cfgBlock := ctx.String("cfg-block")
|
||||
if cfgBlock == "" {
|
||||
return nil, errors.New("Error: cfg-block is required")
|
||||
return nil, nil, errors.New("Error: cfg-block is required")
|
||||
}
|
||||
var mod maddy.ModInfo
|
||||
for _, m := range mods {
|
||||
if m.Instance.InstanceName() == cfgBlock {
|
||||
mod = m
|
||||
break
|
||||
}
|
||||
}
|
||||
if mod.Instance == nil {
|
||||
return nil, nil, fmt.Errorf("Error: unknown configuration block: %s", cfgBlock)
|
||||
}
|
||||
|
||||
root, node, err := findBlockInCfg(cfgPath, cfgBlock)
|
||||
return globals, &mod, nil
|
||||
}
|
||||
|
||||
func openStorage(ctx *cli.Context) (module.Storage, error) {
|
||||
globals, mod, err := getCfgBlockModule(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var store Storage
|
||||
switch node.Name {
|
||||
case "imapsql":
|
||||
store, err = sqlFromCfgBlock(root, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, errors.New("Error: Storage backend is not supported by maddyctl")
|
||||
storage, ok := mod.Instance.(module.Storage)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Error: configuration block %s is not an IMAP storage", ctx.String("cfg-block"))
|
||||
}
|
||||
|
||||
if updStore, ok := store.(updatepipe.Backend); ok {
|
||||
if err := mod.Instance.Init(config.NewMap(globals, mod.Cfg)); err != nil {
|
||||
return nil, fmt.Errorf("Error: module initialization failed: %w", err)
|
||||
}
|
||||
|
||||
if updStore, ok := mod.Instance.(updatepipe.Backend); ok {
|
||||
if err := updStore.EnableUpdatePipe(updatepipe.ModePush); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
fmt.Fprintf(os.Stderr, "Failed to initialize update pipe, do not remove messages from mailboxes open by clients: %v\n", err)
|
||||
}
|
||||
|
@ -636,29 +726,23 @@ func openStorage(ctx *cli.Context) (Storage, error) {
|
|||
fmt.Fprintf(os.Stderr, "No update pipe support, do not remove messages from mailboxes open by clients\n")
|
||||
}
|
||||
|
||||
return store, nil
|
||||
return storage, nil
|
||||
}
|
||||
|
||||
func openUserDB(ctx *cli.Context) (UserDB, error) {
|
||||
cfgPath := ctx.GlobalString("config")
|
||||
if cfgPath == "" {
|
||||
return nil, errors.New("Error: config is required")
|
||||
}
|
||||
|
||||
cfgBlock := ctx.String("cfg-block")
|
||||
if cfgBlock == "" {
|
||||
return nil, errors.New("Error: cfg-block is required")
|
||||
}
|
||||
|
||||
root, node, err := findBlockInCfg(cfgPath, cfgBlock)
|
||||
func openUserDB(ctx *cli.Context) (module.PlainUserDB, error) {
|
||||
globals, mod, err := getCfgBlockModule(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch node.Name {
|
||||
case "imapsql":
|
||||
return sqlFromCfgBlock(root, node)
|
||||
default:
|
||||
return nil, errors.New("Error: Authentication backend is not supported by maddyctl")
|
||||
userDB, ok := mod.Instance.(module.PlainUserDB)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Error: configuration block %s is not a local credentials store", ctx.String("cfg-block"))
|
||||
}
|
||||
|
||||
if err := mod.Instance.Init(config.NewMap(globals, mod.Cfg)); err != nil {
|
||||
return nil, fmt.Errorf("Error: module initialization failed: %w", err)
|
||||
}
|
||||
|
||||
return userDB, nil
|
||||
}
|
||||
|
|
|
@ -1,35 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"github.com/foxcpp/maddy/internal/config"
|
||||
"github.com/foxcpp/maddy/internal/storage/imapsql"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
func sqlFromCfgBlock(root, node config.Node) (*imapsql.Storage, error) {
|
||||
// Global variables relevant for sql module.
|
||||
globals := config.NewMap(nil, root)
|
||||
// None now...
|
||||
globals.AllowUnknown()
|
||||
_, err := globals.Process()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
instName := "imapsql"
|
||||
if len(node.Args) >= 1 {
|
||||
instName = node.Args[0]
|
||||
}
|
||||
|
||||
mod, err := imapsql.New("imapsql", instName, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mod.Init(config.NewMap(globals.Values, node)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return mod.(*imapsql.Storage), nil
|
||||
}
|
|
@ -1,5 +0,0 @@
|
|||
// +build cgo,!nosqlite3
|
||||
|
||||
package main
|
||||
|
||||
import _ "github.com/mattn/go-sqlite3"
|
|
@ -5,13 +5,12 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
|
||||
imapsql "github.com/foxcpp/go-imap-sql"
|
||||
"github.com/foxcpp/maddy/cmd/maddyctl/clitools"
|
||||
"github.com/foxcpp/maddy/internal/module"
|
||||
"github.com/urfave/cli"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func usersList(be UserDB, ctx *cli.Context) error {
|
||||
func usersList(be module.PlainUserDB, ctx *cli.Context) error {
|
||||
list, err := be.ListUsers()
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -27,41 +26,12 @@ func usersList(be UserDB, ctx *cli.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func usersCreate(be UserDB, ctx *cli.Context) error {
|
||||
func usersCreate(be module.PlainUserDB, ctx *cli.Context) error {
|
||||
username := ctx.Args().First()
|
||||
if username == "" {
|
||||
return errors.New("Error: USERNAME is required")
|
||||
}
|
||||
|
||||
if ctx.IsSet("null") {
|
||||
return be.CreateUserNoPass(username)
|
||||
}
|
||||
|
||||
if ctx.IsSet("hash") {
|
||||
// XXX: This needs to be updated to work with other backends in future.
|
||||
sqlbe, ok := be.(*imapsql.Backend)
|
||||
if !ok {
|
||||
return errors.New("Error: Storage does not support custom hash functions")
|
||||
}
|
||||
sqlbe.Opts.DefaultHashAlgo = ctx.String("hash")
|
||||
}
|
||||
if ctx.IsSet("bcrypt-cost") {
|
||||
if ctx.Int("bcrypt-cost") > bcrypt.MaxCost {
|
||||
return errors.New("Error: too big bcrypt cost")
|
||||
}
|
||||
if ctx.Int("bcrypt-cost") < bcrypt.MinCost {
|
||||
return errors.New("Error: too small bcrypt cost")
|
||||
}
|
||||
|
||||
// XXX: This needs to be updated to work with other backends in future.
|
||||
sqlbe, ok := be.(*imapsql.Backend)
|
||||
if !ok {
|
||||
return errors.New("Error: Storage does not support custom hash cost")
|
||||
}
|
||||
|
||||
sqlbe.Opts.BcryptCost = ctx.Int("bcrypt-cost")
|
||||
}
|
||||
|
||||
var pass string
|
||||
if ctx.IsSet("password") {
|
||||
pass = ctx.String("password")
|
||||
|
@ -76,7 +46,7 @@ func usersCreate(be UserDB, ctx *cli.Context) error {
|
|||
return be.CreateUser(username, pass)
|
||||
}
|
||||
|
||||
func usersRemove(be UserDB, ctx *cli.Context) error {
|
||||
func usersRemove(be module.PlainUserDB, ctx *cli.Context) error {
|
||||
username := ctx.Args().First()
|
||||
if username == "" {
|
||||
return errors.New("Error: USERNAME is required")
|
||||
|
@ -91,48 +61,12 @@ func usersRemove(be UserDB, ctx *cli.Context) error {
|
|||
return be.DeleteUser(username)
|
||||
}
|
||||
|
||||
func usersPassword(be UserDB, ctx *cli.Context) error {
|
||||
func usersPassword(be module.PlainUserDB, ctx *cli.Context) error {
|
||||
username := ctx.Args().First()
|
||||
if username == "" {
|
||||
return errors.New("Error: USERNAME is required")
|
||||
}
|
||||
|
||||
if ctx.IsSet("null") {
|
||||
type ResetPassBack interface {
|
||||
ResetPassword(string) error
|
||||
}
|
||||
rpbe, ok := be.(ResetPassBack)
|
||||
if !ok {
|
||||
return errors.New("Error: Storage does not support null passwords")
|
||||
}
|
||||
return rpbe.ResetPassword(username)
|
||||
}
|
||||
|
||||
if ctx.IsSet("hash") {
|
||||
// XXX: This needs to be updated to work with other backends in future.
|
||||
sqlbe, ok := be.(*imapsql.Backend)
|
||||
if !ok {
|
||||
return errors.New("Error: Storage does not support custom hash functions")
|
||||
}
|
||||
sqlbe.Opts.DefaultHashAlgo = ctx.String("hash")
|
||||
}
|
||||
if ctx.IsSet("bcrypt-cost") {
|
||||
if ctx.Int("bcrypt-cost") > bcrypt.MaxCost {
|
||||
return errors.New("Error: too big bcrypt cost")
|
||||
}
|
||||
if ctx.Int("bcrypt-cost") < bcrypt.MinCost {
|
||||
return errors.New("Error: too small bcrypt cost")
|
||||
}
|
||||
|
||||
// XXX: This needs to be updated to work with other backends in future.
|
||||
sqlbe, ok := be.(*imapsql.Backend)
|
||||
if !ok {
|
||||
return errors.New("Error: Storage does not support custom hash cost")
|
||||
}
|
||||
|
||||
sqlbe.Opts.BcryptCost = ctx.Int("bcrypt-cost")
|
||||
}
|
||||
|
||||
var pass string
|
||||
if ctx.IsSet("password") {
|
||||
pass = ctx.String("password")
|
||||
|
|
|
@ -11,13 +11,6 @@ That is, they authenticate users.
|
|||
Most likely, you are going to use these modules with 'auth' directive of IMAP
|
||||
(*maddy-imap*(5)) or SMTP endpoint (*maddy-smtp*(5)).
|
||||
|
||||
# SQL module (sql)
|
||||
|
||||
sql module described in *maddy-storage*(5) can also be used as a authentication
|
||||
backend.
|
||||
|
||||
The username is required to be a valid RFC 5321 e-mail address.
|
||||
|
||||
# External authentication module (extauth)
|
||||
|
||||
Module for authentication using external helper binary. It looks for binary
|
||||
|
@ -155,6 +148,13 @@ to load user credentials from text file (file_table module) or SQL query
|
|||
|
||||
Definition:
|
||||
```
|
||||
pass_table [block name] {
|
||||
table <table config>
|
||||
|
||||
}
|
||||
```
|
||||
Shortened variant for inline use:
|
||||
```
|
||||
pass_table <table> [table arguments] {
|
||||
[additional table config]
|
||||
}
|
||||
|
@ -176,6 +176,13 @@ hash algorithm name, salt and other necessary parameters.
|
|||
You should use 'maddyctl hash' command to generate suitable values.
|
||||
See 'maddyctl hash --help' for details.
|
||||
|
||||
## maddyctl creds
|
||||
|
||||
If the underlying table is a "mutable" table (see maddy-tables(5)) then
|
||||
the 'maddyctl creds' command can be used to modify the underlying tables
|
||||
via pass_table module. It will act a "local credentials store" and will write
|
||||
appropriate hash values to the table.
|
||||
|
||||
# Separate username and password lookup (plain_separate)
|
||||
|
||||
This module implements authentication using username:password pairs but can
|
||||
|
|
|
@ -12,12 +12,17 @@ configuration directives for each.
|
|||
Most likely, you are going to use modules listed here in 'storage' directive
|
||||
for IMAP endpoint module (see *maddy-imap*(5)).
|
||||
|
||||
In most cases, local storage modules will auto-create accounts when they are
|
||||
accessed via IMAP. This relies on authentication provider used by IMAP endpoint
|
||||
to provide what essentially is access control. There is a caveat, however: this
|
||||
auto-creation will not happen when delivering incoming messages via SMTP as
|
||||
there is no authentication to confirm that this account should indeed be
|
||||
created.
|
||||
|
||||
# SQL-based database module (imapsql)
|
||||
|
||||
The 'imapsql' module implements unified database for IMAP index and the user
|
||||
credentials using SQL-based relational database. This allows easier management
|
||||
as there is no storage accounts and no authentication accounts. There are just
|
||||
accounts that can be created and removed using 'maddyctl' command.
|
||||
The imapsql module implements unified database for IMAP index and message
|
||||
metadata using SQL-based relational database.
|
||||
|
||||
Message contents are stored in an "external store", currently the only
|
||||
supported "external store" is a filesystem directory, used by default.
|
||||
|
|
|
@ -6,6 +6,11 @@ Whenever you need to replace one string with another when handling anything in
|
|||
maddy, you can use any of the following modules to obtain the replacement
|
||||
string. They are commonly called "table modules" or just "tables".
|
||||
|
||||
Some table modules implement write options allowing other maddy modules to
|
||||
change the source of data, effectively turning the table into a complete
|
||||
interface to a key-value store for maddy. Such tables are referred to as
|
||||
"mutable tables".
|
||||
|
||||
# File mapping (file_table)
|
||||
|
||||
This module builds string-string mapping from a text file.
|
||||
|
@ -53,14 +58,23 @@ aaa: bbb
|
|||
aaa
|
||||
```
|
||||
|
||||
# SQL query mapping (sql_table)
|
||||
# SQL query mapping (sql_query)
|
||||
|
||||
The sql_query module implements table interface using SQL queries.
|
||||
|
||||
Definition:
|
||||
```
|
||||
sql_table {
|
||||
sql_query {
|
||||
driver <driver name>
|
||||
dsn <data source name>
|
||||
lookup <lookup query>
|
||||
|
||||
# Optional:
|
||||
init <init query list>
|
||||
list <list query>
|
||||
add <add query>
|
||||
del <del query>
|
||||
set <set query>
|
||||
}
|
||||
```
|
||||
|
||||
|
@ -68,7 +82,7 @@ Usage example:
|
|||
```
|
||||
# Resolve SMTP address aliases using PostgreSQL DB.
|
||||
modify {
|
||||
alias sql_table {
|
||||
alias sql_query {
|
||||
driver postgres
|
||||
dsn "dbname=maddy user=maddy"
|
||||
lookup "SELECT alias FROM aliases WHERE address = $1"
|
||||
|
@ -111,7 +125,7 @@ List of queries to execute on initialization. Can be used to configure RDBMS.
|
|||
|
||||
Example, to improve SQLite3 performance:
|
||||
```
|
||||
sql_table {
|
||||
sql_query {
|
||||
driver sqlite3
|
||||
dsn whatever.db
|
||||
init "PRAGMA journal_mode=WAL" \
|
||||
|
@ -120,6 +134,27 @@ sql_table {
|
|||
}
|
||||
```
|
||||
|
||||
**Syntax:** add _query_ ++
|
||||
**Syntax:** list _query_ ++
|
||||
**Syntax:** set _query_ ++
|
||||
**Syntax:** del _query_ ++
|
||||
**Default:** none
|
||||
|
||||
If queries are set to implement corresponding table operations - table becomes
|
||||
"mutable" and can be used in contexts that require writable key-value store.
|
||||
|
||||
'add' query gets two ordered arguments - key and value strings to store.
|
||||
They should be added to the store. The query *should* not add multiple values
|
||||
for the same key and *should* fail if the key already exists.
|
||||
|
||||
'list' query gets no arguments and should return a column with all keys in
|
||||
the store.
|
||||
|
||||
'set' query gets two arguments - key and value and should replace the existing
|
||||
entry in the database.
|
||||
|
||||
'del' query gets one argument - key and should remove it from the database.
|
||||
|
||||
# Static table (static)
|
||||
|
||||
The 'static' module implements table lookups using key-value pairs in its
|
||||
|
|
|
@ -57,7 +57,8 @@ basic ideas about how email works.
|
|||
zone to make signing work.
|
||||
7. Create user accounts you need using `maddyctl`:
|
||||
```
|
||||
maddyctl users create foxcpp@example.org
|
||||
maddyctl creds create foxcpp@example.org
|
||||
maddyctl imap-acct create foxcpp@example.org
|
||||
```
|
||||
|
||||
Congratulations, now you have your working mail server.
|
||||
|
|
|
@ -154,24 +154,38 @@ mx: mx1.example.org
|
|||
mx: mx2.example.org
|
||||
```
|
||||
|
||||
## postmaster and other user accounts
|
||||
## User accounts and maddyctl
|
||||
|
||||
A mail server is useless without mailboxes, right? Unlike software like postfix
|
||||
and dovecot, maddy uses "virtual users" by default, meaning it does not care or
|
||||
know about system users.
|
||||
|
||||
Here is the command to create virtual 'postmaster' account, it will prompt you
|
||||
for a password:
|
||||
IMAP mailboxes ("accounts") and authentication credentials are kept separate.
|
||||
|
||||
To register user credentials, use `maddyctl creds create` command.
|
||||
Like that:
|
||||
```
|
||||
$ maddyctl users create postmaster@example.org
|
||||
$ maddyctl creds create postmaster@example.org
|
||||
```
|
||||
|
||||
Note that account names include the domain. When authenticating in the mail
|
||||
client, full address should be specified as a username as well.
|
||||
Note the username is a e-mail address. This is required as username is used to
|
||||
authorize IMAP and SMTP access (unless you configure custom mappings, not
|
||||
described here).
|
||||
|
||||
Btw, it is a good idea to learn what else maddyctl can do. Given the
|
||||
non-standard structure of messages storage, maddyctl is the only way to
|
||||
comfortably inspect it.
|
||||
After registering the user credentials, you also need to create a local
|
||||
storage account:
|
||||
```
|
||||
$ maddyctl imap-acct create postmaster@example.org
|
||||
``
|
||||
|
||||
That is it. Now you have your first e-mail address. when authenticating using
|
||||
your e-mail client, do not forget the username is "postmaster@example.org", not
|
||||
just "postmaster".
|
||||
|
||||
You may find running `maddyctl creds --help` and `maddyctl imap-acct --help`
|
||||
useful to learn about other commands. Note that IMAP accounts and credentials
|
||||
are managed separately yet usernames should match by default for things to
|
||||
work.
|
||||
|
||||
## Optional: Install and use fail2ban
|
||||
|
||||
|
|
2
go.mod
2
go.mod
|
@ -18,7 +18,7 @@ require (
|
|||
github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b
|
||||
github.com/emersion/go-smtp v0.12.2-0.20200219094142-f9be832b5554
|
||||
github.com/foxcpp/go-imap-i18nlevel v0.0.0-20200208001533-d6ec88553005
|
||||
github.com/foxcpp/go-imap-sql v0.3.2-0.20200215215045-d7d4cb3f7d1d
|
||||
github.com/foxcpp/go-imap-sql v0.4.1-0.20200411182958-971cf71cce4f
|
||||
github.com/foxcpp/go-mockdns v0.0.0-20191226172053-3b5a6e57c8fe
|
||||
github.com/foxcpp/go-mtasts v0.0.0-20191219193356-62bc3f1f74b8
|
||||
github.com/go-sql-driver/mysql v1.5.0
|
||||
|
|
3
go.sum
3
go.sum
|
@ -54,10 +54,13 @@ github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe h1:40SWqY0
|
|||
github.com/emersion/go-textwrapper v0.0.0-20160606182133-d0e65e56babe/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||
github.com/foxcpp/go-imap-backend-tests v0.0.0-20200104150404-bd7815ad9f5a h1:0M1igChqRDhGcWrjuAq+BiYC7CdVCsyktmbKWlJeuuo=
|
||||
github.com/foxcpp/go-imap-backend-tests v0.0.0-20200104150404-bd7815ad9f5a/go.mod h1:yUISYv/uXLQ6tQZcds/p/hdcZ5JzrEUifyED2VffWpc=
|
||||
github.com/foxcpp/go-imap-backend-tests v0.0.0-20200411182408-7dae2bf0e51c/go.mod h1:yUISYv/uXLQ6tQZcds/p/hdcZ5JzrEUifyED2VffWpc=
|
||||
github.com/foxcpp/go-imap-i18nlevel v0.0.0-20200208001533-d6ec88553005 h1:pfoFtkTTQ473qStSN79jhCFBWqMQt/3DQ3NGuXvT+50=
|
||||
github.com/foxcpp/go-imap-i18nlevel v0.0.0-20200208001533-d6ec88553005/go.mod h1:34FwxnjC2N+EFs2wMtsHevrZLWRKRuVU8wEcHWKq/nE=
|
||||
github.com/foxcpp/go-imap-sql v0.3.2-0.20200215215045-d7d4cb3f7d1d h1:xhC9VM2zgsjtuBHj1Vo9+KCcGZ57uLCcTgkFrEW/p6Y=
|
||||
github.com/foxcpp/go-imap-sql v0.3.2-0.20200215215045-d7d4cb3f7d1d/go.mod h1:3hC9ByCxfNVv8jlxIXYsCLb4ylyq4jbBG5COUln3stE=
|
||||
github.com/foxcpp/go-imap-sql v0.4.1-0.20200411182958-971cf71cce4f h1:B9z1jQiow9anZnpwDBdJqfSAX340u4+5PHv5gZQnGns=
|
||||
github.com/foxcpp/go-imap-sql v0.4.1-0.20200411182958-971cf71cce4f/go.mod h1:EIJTCZYjDmaYB3pquPs+FZcX5YxrLAUZSvwCNLGWblk=
|
||||
github.com/foxcpp/go-mockdns v0.0.0-20191216195825-5eabd8dbfe1f h1:b/CFmrdqIGU6eV774xeaQwd1VfgiLuR/8jiY3LyLiMc=
|
||||
github.com/foxcpp/go-mockdns v0.0.0-20191216195825-5eabd8dbfe1f/go.mod h1:tPg4cp4nseejPd+UKxtCVQ2hUxNTZ7qQZJa7CLriIeo=
|
||||
github.com/foxcpp/go-mockdns v0.0.0-20191226172053-3b5a6e57c8fe h1:vzxspt1t/cOPBbDoIfVdS+7Ytdb5B5BJN46fDMCTxkY=
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
"github.com/foxcpp/maddy/internal/config"
|
||||
modconfig "github.com/foxcpp/maddy/internal/config/module"
|
||||
"github.com/foxcpp/maddy/internal/module"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"golang.org/x/text/secure/precis"
|
||||
)
|
||||
|
||||
|
@ -19,10 +20,6 @@ type Auth struct {
|
|||
}
|
||||
|
||||
func New(modName, instName string, _, inlineArgs []string) (module.Module, error) {
|
||||
if len(inlineArgs) < 1 {
|
||||
return nil, fmt.Errorf("%s: specify the table to use", modName)
|
||||
}
|
||||
|
||||
return &Auth{
|
||||
modName: modName,
|
||||
instName: instName,
|
||||
|
@ -31,7 +28,13 @@ func New(modName, instName string, _, inlineArgs []string) (module.Module, error
|
|||
}
|
||||
|
||||
func (a *Auth) Init(cfg *config.Map) error {
|
||||
return modconfig.ModuleFromNode(a.inlineArgs, cfg.Block, cfg.Globals, &a.table)
|
||||
if len(a.inlineArgs) != 0 {
|
||||
return modconfig.ModuleFromNode(a.inlineArgs, cfg.Block, cfg.Globals, &a.table)
|
||||
}
|
||||
|
||||
cfg.Custom("table", false, true, nil, modconfig.TableDirective, &a.table)
|
||||
_, err := cfg.Process()
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *Auth) Name() string {
|
||||
|
@ -58,15 +61,103 @@ func (a *Auth) AuthPlain(username, password string) error {
|
|||
|
||||
parts := strings.SplitN(hash, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("%s: no hash tag", a.modName)
|
||||
return fmt.Errorf("%s: auth plain %s: no hash tag", a.modName, key)
|
||||
}
|
||||
hashVerify := HashVerify[parts[0]]
|
||||
if hashVerify == nil {
|
||||
return fmt.Errorf("%s: unknown hash: %s", a.modName, parts[0])
|
||||
return fmt.Errorf("%s: auth plain %s: unknown hash: %s", a.modName, key, parts[0])
|
||||
}
|
||||
return hashVerify(password, parts[1])
|
||||
}
|
||||
|
||||
func (a *Auth) ListUsers() ([]string, error) {
|
||||
tbl, ok := a.table.(module.MutableTable)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s: table is not mutable, no management functionality available", a.modName)
|
||||
}
|
||||
|
||||
l, err := tbl.Keys()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: list users: %w", a.modName, err)
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func (a *Auth) CreateUser(username, password string) error {
|
||||
tbl, ok := a.table.(module.MutableTable)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s: table is not mutable, no management functionality available", a.modName)
|
||||
}
|
||||
|
||||
key, err := precis.UsernameCaseMapped.CompareKey(username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: create user %s (raw): %w", a.modName, username, err)
|
||||
}
|
||||
|
||||
_, ok, err = tbl.Lookup(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: create user %s: %w", a.modName, key, err)
|
||||
}
|
||||
if ok {
|
||||
return fmt.Errorf("%s: credentials for %s already exist", a.modName, key)
|
||||
}
|
||||
|
||||
// TODO: Allow to customize hash function.
|
||||
hash, err := HashCompute[HashBcrypt](HashOpts{
|
||||
BcryptCost: bcrypt.DefaultCost,
|
||||
}, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: create user %s: hash generation: %w", a.modName, key, err)
|
||||
}
|
||||
|
||||
if err := tbl.SetKey(key, "bcrypt:"+hash); err != nil {
|
||||
return fmt.Errorf("%s: create user %s: %w", a.modName, key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Auth) SetUserPassword(username, password string) error {
|
||||
tbl, ok := a.table.(module.MutableTable)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s: table is not mutable, no management functionality available", a.modName)
|
||||
}
|
||||
|
||||
key, err := precis.UsernameCaseMapped.CompareKey(username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: set password %s (raw): %w", a.modName, username, err)
|
||||
}
|
||||
|
||||
// TODO: Allow to customize hash function.
|
||||
hash, err := HashCompute[HashBcrypt](HashOpts{
|
||||
BcryptCost: bcrypt.DefaultCost,
|
||||
}, password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: set password %s: hash generation: %w", a.modName, key, err)
|
||||
}
|
||||
|
||||
if err := tbl.SetKey(key, "bcrypt:"+hash); err != nil {
|
||||
return fmt.Errorf("%s: set password %s: %w", a.modName, key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Auth) DeleteUser(username string) error {
|
||||
tbl, ok := a.table.(module.MutableTable)
|
||||
if !ok {
|
||||
return fmt.Errorf("%s: table is not mutable, no management functionality available", a.modName)
|
||||
}
|
||||
|
||||
key, err := precis.UsernameCaseMapped.CompareKey(username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: del user %s (raw): %w", a.modName, username, err)
|
||||
}
|
||||
|
||||
if err := tbl.RemoveKey(key); err != nil {
|
||||
return fmt.Errorf("%s: del user %s: %w", a.modName, key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
module.Register("pass_table", New)
|
||||
}
|
||||
|
|
|
@ -201,7 +201,7 @@ func (endp *Endpoint) Close() error {
|
|||
}
|
||||
|
||||
func (endp *Endpoint) openAccount(c imapserver.Conn, identity string) error {
|
||||
u, err := endp.Store.GetOrCreateUser(identity)
|
||||
u, err := endp.Store.GetOrCreateIMAPAcct(identity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -218,7 +218,7 @@ func (endp *Endpoint) Login(connInfo *imap.ConnInfo, username, password string)
|
|||
return nil, imapbackend.ErrInvalidCredentials
|
||||
}
|
||||
|
||||
return endp.Store.GetOrCreateUser(username)
|
||||
return endp.Store.GetOrCreateIMAPAcct(username)
|
||||
}
|
||||
|
||||
func (endp *Endpoint) EnableChildrenExt() bool {
|
||||
|
|
|
@ -14,3 +14,13 @@ var (
|
|||
type PlainAuth interface {
|
||||
AuthPlain(username, password string) error
|
||||
}
|
||||
|
||||
// PlainUserDB is a local credentials store that can be managed using maddyctl
|
||||
// utility.
|
||||
type PlainUserDB interface {
|
||||
PlainAuth
|
||||
ListUsers() ([]string, error)
|
||||
CreateUser(username, password string) error
|
||||
SetUserPassword(username, password string) error
|
||||
DeleteUser(username string) error
|
||||
}
|
||||
|
|
|
@ -5,12 +5,23 @@ import imapbackend "github.com/emersion/go-imap/backend"
|
|||
// Storage interface is a slightly modified go-imap's Backend interface
|
||||
// (authentication is removed).
|
||||
type Storage interface {
|
||||
// GetOrCreateUser returns User associated with user account specified by
|
||||
// name.
|
||||
// GetOrCreateIMAPAcct returns User associated with storage account specified by
|
||||
// the name.
|
||||
//
|
||||
// If it doesn't exists - it should be created.
|
||||
GetOrCreateUser(username string) (imapbackend.User, error)
|
||||
GetOrCreateIMAPAcct(username string) (imapbackend.User, error)
|
||||
GetIMAPAcct(username string) (imapbackend.User, error)
|
||||
|
||||
// Extensions returns list of IMAP extensions supported by backend.
|
||||
IMAPExtensions() []string
|
||||
}
|
||||
|
||||
// ManageableStorage is an extended Storage interface that allows to
|
||||
// list existing accounts, create and delete them.
|
||||
type ManageableStorage interface {
|
||||
Storage
|
||||
|
||||
ListAccts() ([]string, error)
|
||||
CreateAcct(username string) error
|
||||
DeleteAcct(username string) error
|
||||
}
|
||||
|
|
|
@ -5,3 +5,10 @@ package module
|
|||
type Table interface {
|
||||
Lookup(s string) (string, bool, error)
|
||||
}
|
||||
|
||||
type MutableTable interface {
|
||||
Table
|
||||
Keys() ([]string, error)
|
||||
RemoveKey(k string) error
|
||||
SetKey(k, v string) error
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@ package imapsql
|
|||
|
||||
import (
|
||||
"flag"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
@ -41,46 +40,34 @@ func createTestDB(tb testing.TB, compAlgo string) *Storage {
|
|||
}
|
||||
|
||||
func BenchmarkStorage_Delivery(b *testing.B) {
|
||||
randomKey := "rcpt-" + strconv.FormatUint(rand.New(rand.NewSource(time.Now().UnixNano())).Uint64(), 10)
|
||||
randomKey := "rcpt-" + strconv.FormatInt(time.Now().UnixNano(), 10) + "@example.org"
|
||||
|
||||
be := createTestDB(b, "")
|
||||
if u, err := be.GetOrCreateUser(randomKey); err != nil {
|
||||
if err := be.CreateIMAPAcct(randomKey); err != nil {
|
||||
b.Fatal(err)
|
||||
} else {
|
||||
if err := u.Logout(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
testutils.BenchDelivery(b, be, "sender@example.org", []string{randomKey + "@example.org"})
|
||||
testutils.BenchDelivery(b, be, "sender@example.org", []string{randomKey})
|
||||
}
|
||||
|
||||
func BenchmarkStorage_DeliveryLZ4(b *testing.B) {
|
||||
randomKey := "rcpt-" + strconv.FormatUint(rand.New(rand.NewSource(time.Now().UnixNano())).Uint64(), 10)
|
||||
randomKey := "rcpt-" + strconv.FormatInt(time.Now().UnixNano(), 10) + "@example.org"
|
||||
|
||||
be := createTestDB(b, "lz4")
|
||||
if u, err := be.GetOrCreateUser(randomKey); err != nil {
|
||||
if err := be.CreateIMAPAcct(randomKey); err != nil {
|
||||
b.Fatal(err)
|
||||
} else {
|
||||
if err := u.Logout(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
testutils.BenchDelivery(b, be, "sender@example.org", []string{randomKey + "@example.org"})
|
||||
testutils.BenchDelivery(b, be, "sender@example.org", []string{randomKey})
|
||||
}
|
||||
|
||||
func BenchmarkStorage_DeliveryZstd(b *testing.B) {
|
||||
randomKey := "rcpt-" + strconv.FormatUint(rand.New(rand.NewSource(time.Now().UnixNano())).Uint64(), 10)
|
||||
randomKey := "rcpt-" + strconv.FormatInt(time.Now().UnixNano(), 10) + "@example.org"
|
||||
|
||||
be := createTestDB(b, "zstd")
|
||||
if u, err := be.GetOrCreateUser(randomKey); err != nil {
|
||||
if err := be.CreateIMAPAcct(randomKey); err != nil {
|
||||
b.Fatal(err)
|
||||
} else {
|
||||
if err := u.Logout(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
testutils.BenchDelivery(b, be, "sender@example.org", []string{randomKey + "@example.org"})
|
||||
testutils.BenchDelivery(b, be, "sender@example.org", []string{randomKey})
|
||||
}
|
||||
|
|
|
@ -420,28 +420,7 @@ func prepareUsername(username string) (string, error) {
|
|||
return mbox + "@" + domain, nil
|
||||
}
|
||||
|
||||
func (store *Storage) AuthPlain(username, password string) error {
|
||||
// TODO: Pass session context there.
|
||||
defer trace.StartRegion(context.Background(), "imapsql/AuthPlain").End()
|
||||
|
||||
accountName, err := prepareUsername(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
password, err = precis.OpaqueString.CompareKey(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO(GH foxcpp/go-imap-sql#30): Make go-imap-sql CheckPlain return an actual error.
|
||||
if !store.Back.CheckPlain(accountName, password) {
|
||||
return module.ErrUnknownCredentials
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *Storage) GetOrCreateUser(username string) (backend.User, error) {
|
||||
func (store *Storage) GetOrCreateIMAPAcct(username string) (backend.User, error) {
|
||||
accountName, err := prepareUsername(username)
|
||||
if err != nil {
|
||||
return nil, backend.ErrInvalidCredentials
|
||||
|
|
|
@ -1,47 +1,26 @@
|
|||
package imapsql
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/emersion/go-imap/backend"
|
||||
"golang.org/x/text/secure/precis"
|
||||
)
|
||||
|
||||
// These methods wrap corresponding go-imap-sql methods, but also apply
|
||||
// maddy-specific credentials rules.
|
||||
|
||||
func (store *Storage) ListUsers() ([]string, error) {
|
||||
func (store *Storage) ListIMAPAccts() ([]string, error) {
|
||||
return store.Back.ListUsers()
|
||||
}
|
||||
|
||||
func (store *Storage) CreateUser(username, password string) error {
|
||||
func (store *Storage) CreateIMAPAcct(username string) error {
|
||||
accountName, err := prepareUsername(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
password, err = precis.OpaqueString.CompareKey(password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(password) == 0 {
|
||||
return errors.New("sql: empty passwords are not allowed")
|
||||
}
|
||||
|
||||
return store.Back.CreateUser(accountName, password)
|
||||
return store.Back.CreateUser(accountName)
|
||||
}
|
||||
|
||||
func (store *Storage) CreateUserNoPass(username string) error {
|
||||
accountName, err := prepareUsername(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return store.Back.CreateUserNoPass(accountName)
|
||||
}
|
||||
|
||||
func (store *Storage) DeleteUser(username string) error {
|
||||
func (store *Storage) DeleteIMAPAcct(username string) error {
|
||||
accountName, err := prepareUsername(username)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -50,25 +29,7 @@ func (store *Storage) DeleteUser(username string) error {
|
|||
return store.Back.DeleteUser(accountName)
|
||||
}
|
||||
|
||||
func (store *Storage) SetUserPassword(username, newPassword string) error {
|
||||
accountName, err := prepareUsername(username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
newPassword, err = precis.OpaqueString.CompareKey(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(newPassword) == 0 {
|
||||
return errors.New("sql: empty passwords are not allowed")
|
||||
}
|
||||
|
||||
return store.Back.SetUserPassword(accountName, newPassword)
|
||||
}
|
||||
|
||||
func (store *Storage) GetUser(username string) (backend.User, error) {
|
||||
func (store *Storage) GetIMAPAcct(username string) (backend.User, error) {
|
||||
accountName, err := prepareUsername(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
|
@ -15,7 +15,7 @@ import (
|
|||
"github.com/foxcpp/maddy/internal/module"
|
||||
)
|
||||
|
||||
const FileModName = "file_table"
|
||||
const FileModName = "file"
|
||||
|
||||
type File struct {
|
||||
instName string
|
||||
|
|
|
@ -1,89 +0,0 @@
|
|||
package table
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
|
||||
"github.com/foxcpp/maddy/internal/config"
|
||||
"github.com/foxcpp/maddy/internal/module"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
type SQL struct {
|
||||
modName string
|
||||
instName string
|
||||
|
||||
db *sql.DB
|
||||
lookup *sql.Stmt
|
||||
}
|
||||
|
||||
func NewSQL(modName, instName string, _, _ []string) (module.Module, error) {
|
||||
return &SQL{
|
||||
modName: modName,
|
||||
instName: instName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SQL) Name() string {
|
||||
return s.modName
|
||||
}
|
||||
|
||||
func (s *SQL) InstanceName() string {
|
||||
return s.instName
|
||||
}
|
||||
|
||||
func (s *SQL) Init(cfg *config.Map) error {
|
||||
var (
|
||||
driver string
|
||||
initQueries []string
|
||||
dsnParts []string
|
||||
lookupQuery string
|
||||
)
|
||||
cfg.StringList("init", false, false, nil, &initQueries)
|
||||
cfg.String("driver", false, true, "", &driver)
|
||||
cfg.StringList("dsn", false, true, nil, &dsnParts)
|
||||
cfg.String("lookup", false, true, "", &lookupQuery)
|
||||
if _, err := cfg.Process(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := sql.Open(driver, strings.Join(dsnParts, " "))
|
||||
if err != nil {
|
||||
return config.NodeErr(cfg.Block, "failed to open db: %v", err)
|
||||
}
|
||||
s.db = db
|
||||
|
||||
for _, init := range initQueries {
|
||||
if _, err := db.Exec(init); err != nil {
|
||||
return config.NodeErr(cfg.Block, "init query failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
s.lookup, err = db.Prepare(lookupQuery)
|
||||
if err != nil {
|
||||
return config.NodeErr(cfg.Block, "failed to prepare lookup query: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQL) Close() error {
|
||||
s.lookup.Close()
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *SQL) Lookup(val string) (string, bool, error) {
|
||||
var repl string
|
||||
row := s.lookup.QueryRow(val)
|
||||
if err := row.Scan(&repl); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, err
|
||||
}
|
||||
return repl, true, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
module.Register("sql_table", NewSQL)
|
||||
}
|
179
internal/table/sql_query.go
Normal file
179
internal/table/sql_query.go
Normal file
|
@ -0,0 +1,179 @@
|
|||
package table
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/foxcpp/maddy/internal/config"
|
||||
"github.com/foxcpp/maddy/internal/module"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
type SQL struct {
|
||||
modName string
|
||||
instName string
|
||||
|
||||
db *sql.DB
|
||||
lookup *sql.Stmt
|
||||
add *sql.Stmt
|
||||
list *sql.Stmt
|
||||
set *sql.Stmt
|
||||
del *sql.Stmt
|
||||
}
|
||||
|
||||
func NewSQL(modName, instName string, _, _ []string) (module.Module, error) {
|
||||
return &SQL{
|
||||
modName: modName,
|
||||
instName: instName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SQL) Name() string {
|
||||
return s.modName
|
||||
}
|
||||
|
||||
func (s *SQL) InstanceName() string {
|
||||
return s.instName
|
||||
}
|
||||
|
||||
func (s *SQL) Init(cfg *config.Map) error {
|
||||
var (
|
||||
driver string
|
||||
initQueries []string
|
||||
dsnParts []string
|
||||
lookupQuery string
|
||||
|
||||
addQuery string
|
||||
listQuery string
|
||||
removeQuery string
|
||||
setQuery string
|
||||
)
|
||||
cfg.StringList("init", false, false, nil, &initQueries)
|
||||
cfg.String("driver", false, true, "", &driver)
|
||||
cfg.StringList("dsn", false, true, nil, &dsnParts)
|
||||
|
||||
cfg.String("lookup", false, true, "", &lookupQuery)
|
||||
|
||||
cfg.String("add", false, false, "", &addQuery)
|
||||
cfg.String("list", false, false, "", &listQuery)
|
||||
cfg.String("del", false, false, "", &removeQuery)
|
||||
cfg.String("set", false, false, "", &setQuery)
|
||||
if _, err := cfg.Process(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := sql.Open(driver, strings.Join(dsnParts, " "))
|
||||
if err != nil {
|
||||
return config.NodeErr(cfg.Block, "failed to open db: %v", err)
|
||||
}
|
||||
s.db = db
|
||||
|
||||
for _, init := range initQueries {
|
||||
if _, err := db.Exec(init); err != nil {
|
||||
return config.NodeErr(cfg.Block, "init query failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
s.lookup, err = db.Prepare(lookupQuery)
|
||||
if err != nil {
|
||||
return config.NodeErr(cfg.Block, "failed to prepare lookup query: %v", err)
|
||||
}
|
||||
if addQuery != "" {
|
||||
s.add, err = db.Prepare(addQuery)
|
||||
if err != nil {
|
||||
return config.NodeErr(cfg.Block, "failed to prepare add query: %v", err)
|
||||
}
|
||||
}
|
||||
if listQuery != "" {
|
||||
s.list, err = db.Prepare(listQuery)
|
||||
if err != nil {
|
||||
return config.NodeErr(cfg.Block, "failed to prepare list query: %v", err)
|
||||
}
|
||||
}
|
||||
if setQuery != "" {
|
||||
s.set, err = db.Prepare(setQuery)
|
||||
if err != nil {
|
||||
return config.NodeErr(cfg.Block, "failed to prepare set query: %v", err)
|
||||
}
|
||||
}
|
||||
if removeQuery != "" {
|
||||
s.del, err = db.Prepare(removeQuery)
|
||||
if err != nil {
|
||||
return config.NodeErr(cfg.Block, "failed to prepare del query: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQL) Close() error {
|
||||
s.lookup.Close()
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
func (s *SQL) Lookup(val string) (string, bool, error) {
|
||||
var repl string
|
||||
row := s.lookup.QueryRow(val)
|
||||
if err := row.Scan(&repl); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return "", false, nil
|
||||
}
|
||||
return "", false, fmt.Errorf("%s: lookup %s: %w", s.modName, val, err)
|
||||
}
|
||||
return repl, true, nil
|
||||
}
|
||||
|
||||
func (s *SQL) Keys() ([]string, error) {
|
||||
if s.list == nil {
|
||||
return nil, fmt.Errorf("%s: table is not mutable (no 'list' query)", s.modName)
|
||||
}
|
||||
|
||||
rows, err := s.list.Query()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: list: %w", s.modName, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var list []string
|
||||
for rows.Next() {
|
||||
var key string
|
||||
if err := rows.Scan(&key); err != nil {
|
||||
return nil, fmt.Errorf("%s: list: %w", s.modName, err)
|
||||
}
|
||||
list = append(list, key)
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (s *SQL) RemoveKey(k string) error {
|
||||
if s.del == nil {
|
||||
return fmt.Errorf("%s: table is not mutable (no 'del' query)", s.modName)
|
||||
}
|
||||
|
||||
_, err := s.del.Exec(k)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: del %s: %w", s.modName, k, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SQL) SetKey(k, v string) error {
|
||||
if s.set == nil {
|
||||
return fmt.Errorf("%s: table is not mutable (no 'set' query)", s.modName)
|
||||
}
|
||||
if s.add == nil {
|
||||
return fmt.Errorf("%s: table is not mutable (no 'add' query)", s.modName)
|
||||
}
|
||||
|
||||
if _, err := s.add.Exec(k, v); err != nil {
|
||||
if _, err := s.set.Exec(k, v); err != nil {
|
||||
return fmt.Errorf("%s: add %s: %w", s.modName, k, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
module.Register("sql_query", NewSQL)
|
||||
}
|
121
internal/table/sql_table.go
Normal file
121
internal/table/sql_table.go
Normal file
|
@ -0,0 +1,121 @@
|
|||
package table
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/foxcpp/maddy/internal/config"
|
||||
"github.com/foxcpp/maddy/internal/module"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
type SQLTable struct {
|
||||
modName string
|
||||
instName string
|
||||
|
||||
wrapped *SQL
|
||||
}
|
||||
|
||||
func NewSQLTable(modName, instName string, _, _ []string) (module.Module, error) {
|
||||
return &SQLTable{
|
||||
modName: modName,
|
||||
instName: instName,
|
||||
|
||||
wrapped: &SQL{
|
||||
modName: modName,
|
||||
instName: instName,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SQLTable) Name() string {
|
||||
return s.modName
|
||||
}
|
||||
|
||||
func (s *SQLTable) InstanceName() string {
|
||||
return s.instName
|
||||
}
|
||||
|
||||
func (s *SQLTable) Init(cfg *config.Map) error {
|
||||
var (
|
||||
driver string
|
||||
dsnParts []string
|
||||
tableName string
|
||||
keyColumn string
|
||||
valueColumn string
|
||||
)
|
||||
cfg.String("driver", false, true, "", &driver)
|
||||
cfg.StringList("dsn", false, true, nil, &dsnParts)
|
||||
cfg.String("table_name", false, true, "", &tableName)
|
||||
cfg.String("key_column", false, false, "key", &keyColumn)
|
||||
cfg.String("value_column", false, false, "value", &valueColumn)
|
||||
if _, err := cfg.Process(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// sql_table module literally wraps the sql_query module by generating a
|
||||
// configuration block for it.
|
||||
|
||||
return s.wrapped.Init(config.NewMap(cfg.Globals, config.Node{
|
||||
Children: []config.Node{
|
||||
{
|
||||
Name: "driver",
|
||||
Args: []string{driver},
|
||||
},
|
||||
{
|
||||
Name: "dsn",
|
||||
Args: dsnParts,
|
||||
},
|
||||
{
|
||||
Name: "lookup",
|
||||
Args: []string{fmt.Sprintf("SELECT %s FROM %s WHERE %s = $1", valueColumn, tableName, keyColumn)},
|
||||
},
|
||||
{
|
||||
Name: "add",
|
||||
Args: []string{fmt.Sprintf("INSERT INTO %s(%s, %s) VALUES($1, $2)", tableName, keyColumn, valueColumn)},
|
||||
},
|
||||
{
|
||||
Name: "list",
|
||||
Args: []string{fmt.Sprintf("SELECT %s from %s", keyColumn, tableName)},
|
||||
},
|
||||
{
|
||||
Name: "set",
|
||||
Args: []string{fmt.Sprintf("UPDATE %s SET %s = $2 WHERE %s = $1", tableName, valueColumn, keyColumn)},
|
||||
},
|
||||
{
|
||||
Name: "del",
|
||||
Args: []string{fmt.Sprintf("DELETE FROM %s WHERE %s = $1", tableName, keyColumn)},
|
||||
},
|
||||
{
|
||||
Name: "init",
|
||||
Args: []string{fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s (
|
||||
%s LONGTEXT PRIMARY KEY NOT NULL,
|
||||
%s LONGTEXT NOT NULL
|
||||
)`, tableName, keyColumn, valueColumn)},
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
func (s *SQLTable) Close() error {
|
||||
return s.wrapped.Close()
|
||||
}
|
||||
|
||||
func (s *SQLTable) Lookup(val string) (string, bool, error) {
|
||||
return s.wrapped.Lookup(val)
|
||||
}
|
||||
|
||||
func (s *SQLTable) Keys() ([]string, error) {
|
||||
return s.wrapped.Keys()
|
||||
}
|
||||
|
||||
func (s *SQLTable) RemoveKey(k string) error {
|
||||
return s.wrapped.RemoveKey(k)
|
||||
}
|
||||
|
||||
func (s *SQLTable) SetKey(k, v string) error {
|
||||
return s.wrapped.SetKey(k, v)
|
||||
}
|
||||
|
||||
func init() {
|
||||
module.Register("sql_table", NewSQLTable)
|
||||
}
|
29
maddy.conf
29
maddy.conf
|
@ -20,11 +20,32 @@ tls /etc/maddy/certs/$(hostname)/fullchain.pem /etc/maddy/certs/$(hostname)/priv
|
|||
# ----------------------------------------------------------------------------
|
||||
# Local storage & authentication
|
||||
|
||||
# imapsql modules provides unified database that is used both for user
|
||||
# credentials and IMAP index. Use 'maddyctl users' utility to manage accounts
|
||||
# and 'maddyctl imap-*' commands to inspect stored messages.
|
||||
# pass_table provides local hashed passwords storage for authentication of
|
||||
# users. It can be configured to use any "table" module, in default
|
||||
# configuration a table in SQLite DB is used.
|
||||
# Table can be replaced to use e.g. a file for passwords. Or pass_table module
|
||||
# can be replaced altogether to use some external source of credentials (e.g.
|
||||
# PAM, /etc/shadow file).
|
||||
#
|
||||
# If table module supports it (sql_table does) - credentials can be managed
|
||||
# using 'maddyctl creds' command.
|
||||
|
||||
imapsql local_mailboxes local_authdb {
|
||||
pass_table local_authdb {
|
||||
table sql_table {
|
||||
driver sqlite3
|
||||
dsn credentials.db
|
||||
table_name passwords
|
||||
}
|
||||
}
|
||||
|
||||
# imapsql module stores all indexes and metadata necessary for IMAP using a
|
||||
# relational database. It is used by IMAP endpoint for mailbox access and
|
||||
# also by SMTP & Submission endpoints for delivery of local messages.
|
||||
#
|
||||
# IMAP accounts, mailboxes and all message metadata can be inspected using
|
||||
# imap-* subcommands of maddyctl utility.
|
||||
|
||||
imapsql local_mailboxes {
|
||||
driver sqlite3
|
||||
dsn imapsql.db
|
||||
}
|
||||
|
|
72
maddy.go
72
maddy.go
|
@ -251,7 +251,7 @@ func ensureDirectoryWritable(path string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func moduleMain(cfg []config.Node) error {
|
||||
func ReadGlobals(cfg []config.Node) (map[string]interface{}, []config.Node, error) {
|
||||
globals := config.NewMap(nil, config.Node{Children: cfg})
|
||||
globals.String("state_dir", false, false, DefaultStateDirectory, &config.StateDirectory)
|
||||
globals.String("runtime_dir", false, false, DefaultRuntimeDirectory, &config.RuntimeDirectory)
|
||||
|
@ -265,6 +265,11 @@ func moduleMain(cfg []config.Node) error {
|
|||
globals.Bool("debug", false, log.DefaultLogger.Debug, &log.DefaultLogger.Debug)
|
||||
globals.AllowUnknown()
|
||||
unknown, err := globals.Process()
|
||||
return globals.Values, unknown, err
|
||||
}
|
||||
|
||||
func moduleMain(cfg []config.Node) error {
|
||||
globals, modBlocks, err := ReadGlobals(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -277,7 +282,12 @@ func moduleMain(cfg []config.Node) error {
|
|||
|
||||
hooks.AddHook(hooks.EventLogRotate, reinitLogging)
|
||||
|
||||
_, err = instancesFromConfig(globals.Values, unknown)
|
||||
endpoints, mods, err := RegisterModules(globals, modBlocks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = initModules(globals, endpoints, mods)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -293,16 +303,13 @@ func moduleMain(cfg []config.Node) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
type modInfo struct {
|
||||
instance module.Module
|
||||
cfg config.Node
|
||||
type ModInfo struct {
|
||||
Instance module.Module
|
||||
Cfg config.Node
|
||||
}
|
||||
|
||||
func instancesFromConfig(globals map[string]interface{}, nodes []config.Node) ([]module.Module, error) {
|
||||
var (
|
||||
endpoints []modInfo
|
||||
mods = make([]modInfo, 0, len(nodes))
|
||||
)
|
||||
func RegisterModules(globals map[string]interface{}, nodes []config.Node) (endpoints, mods []ModInfo, err error) {
|
||||
mods = make([]ModInfo, 0, len(nodes))
|
||||
|
||||
for _, block := range nodes {
|
||||
var instName string
|
||||
|
@ -320,73 +327,70 @@ func instancesFromConfig(globals map[string]interface{}, nodes []config.Node) ([
|
|||
if endpFactory != nil {
|
||||
inst, err := endpFactory(modName, block.Args)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
endpoints = append(endpoints, modInfo{instance: inst, cfg: block})
|
||||
endpoints = append(endpoints, ModInfo{Instance: inst, Cfg: block})
|
||||
continue
|
||||
}
|
||||
|
||||
factory := module.Get(modName)
|
||||
if factory == nil {
|
||||
return nil, config.NodeErr(block, "unknown module or global directive: %s", modName)
|
||||
return nil, nil, config.NodeErr(block, "unknown module or global directive: %s", modName)
|
||||
}
|
||||
|
||||
if module.HasInstance(instName) {
|
||||
return nil, config.NodeErr(block, "config block named %s already exists", instName)
|
||||
return nil, nil, config.NodeErr(block, "config block named %s already exists", instName)
|
||||
}
|
||||
|
||||
inst, err := factory(modName, instName, modAliases, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
block := block
|
||||
module.RegisterInstance(inst, config.NewMap(globals, block))
|
||||
for _, alias := range modAliases {
|
||||
if module.HasInstance(alias) {
|
||||
return nil, config.NodeErr(block, "config block named %s already exists", alias)
|
||||
return nil, nil, config.NodeErr(block, "config block named %s already exists", alias)
|
||||
}
|
||||
module.RegisterAlias(alias, instName)
|
||||
}
|
||||
mods = append(mods, modInfo{instance: inst, cfg: block})
|
||||
mods = append(mods, ModInfo{Instance: inst, Cfg: block})
|
||||
}
|
||||
|
||||
if len(endpoints) == 0 {
|
||||
return nil, fmt.Errorf("at least one endpoint should be configured")
|
||||
return nil, nil, fmt.Errorf("at least one endpoint should be configured")
|
||||
}
|
||||
|
||||
return endpoints, mods, nil
|
||||
}
|
||||
|
||||
func initModules(globals map[string]interface{}, endpoints, mods []ModInfo) error {
|
||||
for _, endp := range endpoints {
|
||||
if err := endp.instance.Init(config.NewMap(globals, endp.cfg)); err != nil {
|
||||
return nil, err
|
||||
if err := endp.Instance.Init(config.NewMap(globals, endp.Cfg)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if closer, ok := endp.instance.(io.Closer); ok {
|
||||
if closer, ok := endp.Instance.(io.Closer); ok {
|
||||
endp := endp
|
||||
hooks.AddHook(hooks.EventShutdown, func() {
|
||||
log.Debugf("close %s (%s)", endp.instance.Name(), endp.instance.InstanceName())
|
||||
log.Debugf("close %s (%s)", endp.Instance.Name(), endp.Instance.InstanceName())
|
||||
if err := closer.Close(); err != nil {
|
||||
log.Printf("module %s (%s) close failed: %v", endp.instance.Name(), endp.instance.InstanceName(), err)
|
||||
log.Printf("module %s (%s) close failed: %v", endp.Instance.Name(), endp.Instance.InstanceName(), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, inst := range mods {
|
||||
if module.Initialized[inst.instance.InstanceName()] {
|
||||
if module.Initialized[inst.Instance.InstanceName()] {
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("Unused configuration block at %s:%d - %s (%s)",
|
||||
inst.cfg.File, inst.cfg.Line, inst.instance.InstanceName(), inst.instance.Name())
|
||||
return fmt.Errorf("Unused configuration block at %s:%d - %s (%s)",
|
||||
inst.Cfg.File, inst.Cfg.Line, inst.Instance.InstanceName(), inst.Instance.Name())
|
||||
}
|
||||
|
||||
res := make([]module.Module, 0, len(mods)+len(endpoints))
|
||||
for _, endp := range endpoints {
|
||||
res = append(res, endp.instance)
|
||||
}
|
||||
for _, mod := range mods {
|
||||
res = append(res, mod.instance)
|
||||
}
|
||||
return res, nil
|
||||
return nil
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue