modDovecot/godovecot.go

679 lines
15 KiB
Go

package modDovecot
import (
"encoding/base64"
"encoding/csv"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io/ioutil"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/zloylos/grsync"
"golang.org/x/crypto/ssh"
kh "golang.org/x/crypto/ssh/knownhosts"
"git.ververis.eu/mandi/modDovecot/types"
)
const serialServerPath string = "srv"
type MailServer struct {
ApiConfig *ApiConfig
SSHConfig *SSHConfig
MaildirPath string
BakupMailsPath string
BackupAccountsPath string
MailDirOwner string
MailDirGroup string
}
type server struct {
server *MailServer
sshClientConfig *ssh.ClientConfig
apiKey *ApiKey
sshAddress string
}
type SSHConfig struct {
SSHHost string
SSHPort string
SSHUser string
LocalPrivateKeyPath string
LocalKnownHostPath string
}
type ApiConfig struct {
ApiHost string
ApiUser string
Password string
}
//Begin types
type ApiKey struct {
Key string `json:"key"`
}
type IPacket interface {
getPackets() ([]*types.Packet, error)
}
type PacketValues struct {
Packets []*types.Packet
}
type FullPacket struct {
Packets []*types.Packet
}
type FilePacket struct {
FilePath string
Packets []*types.Packet
}
type AntivirType string
const (
Off AntivirType = "off"
In AntivirType = "in"
Out AntivirType = "out"
Inout AntivirType = "inout"
)
type PasswordType string
const (
Plain PasswordType = "plain"
Crypt PasswordType = "crypt"
)
type ApiAction string
const (
Create ApiAction = "create"
)
//Test
func (s *server) TestSSH() error {
if s.sshClientConfig == nil {
return errors.New("incorrect ssh configuration")
}
conn, err := ssh.Dial("tcp", s.sshAddress, s.sshClientConfig)
if err != nil {
return err
}
defer conn.Close()
session, err := conn.NewSession()
if err != nil {
return err
}
defer session.Close()
out, err := session.CombinedOutput("pwd")
if err != nil {
return err
}
fmt.Println("Test SSH gave back: ", string(out))
return nil
}
//End Test
//End types
//Begin IPacket interface
func (p *PacketValues) AddPacket(siteId int, action ApiAction, name, password string, enabled bool, passwordType PasswordType, antivirType AntivirType) {
np := createPacket(siteId, action, name, password, enabled, passwordType, antivirType)
p.Packets = append(p.Packets, &np)
}
func (p PacketValues) getPackets() ([]*types.Packet, error) {
return p.Packets, nil
}
func (p FullPacket) getPackets() ([]*types.Packet, error) {
return p.Packets, nil
}
func (p FilePacket) getPackets() ([]*types.Packet, error) {
file, err := ioutil.ReadFile(p.FilePath)
if err != nil {
fmt.Println(err)
}
accounts := []types.Account{}
_ = json.Unmarshal([]byte(file), &accounts)
for _, v := range accounts {
name := strings.Split(v.Address, "@")[0]
packet := createPacket(1, Create, name, v.Password, true, Plain, Inout)
p.Packets = append(p.Packets, &packet)
}
return p.Packets, nil
}
//End IPacket interface
//Constructor
func (ms *MailServer) NewServer() *server {
//Make sure to generate server serialisation on program start
srv := server{
server: ms,
}
sshClientConfig, err := srv.getSSHConfig()
if err != nil {
fmt.Println("Constructor: ", err)
}
srv.sshClientConfig = sshClientConfig
srv.sshAddress = srv.server.SSHConfig.SSHHost + ":" + srv.server.SSHConfig.SSHPort
srv.logIn()
return &srv
}
//Begin server implementation
//Stores Backup of mail directory in given folder using rsync
func (s *server) SynchroniseMail(delete bool) (bool, error) {
source := s.server.SSHConfig.SSHUser + "@" + s.server.SSHConfig.SSHHost + ":" + s.server.MaildirPath
fmt.Println(source)
task := grsync.NewTask(
source,
s.server.BakupMailsPath,
grsync.RsyncOptions{
Delete: delete,
},
)
go func() {
for {
state := task.State()
fmt.Printf(
"progress: %.2f / rem. %d / tot. %d / sp. %s \n",
state.Progress,
state.Remain,
state.Total,
state.Speed,
)
<-time.After(time.Second)
return
}
}()
if err := task.Run(); err != nil {
return false, err
}
fmt.Println("well done")
fmt.Println("###LOG###")
fmt.Println(task.Log())
fmt.Println("###END LOG###")
return true, nil
}
func BackupMail() {
}
//Stores Backup of accounts
func (s *server) BackupAccounts() (bool, error) {
accountsList, err := s.createAccountBackup()
if err != nil {
return false, err
}
r := csv.NewReader(strings.NewReader(accountsList))
r.Comma = '|'
r.Comment = '+'
r.FieldsPerRecord = -1
records, err := r.ReadAll()
if err != nil {
return false, err
}
accounts := []types.Account{}
for _, v := range records {
if len(v) == 5 {
address := strings.Replace(v[1], " ", "", -1)
password := strings.Replace(v[3], " ", "", -1)
if address != "address" {
accounts = append(accounts, types.Account{
Address: address,
Password: password,
})
}
}
}
if _, err := os.Stat(s.server.BackupAccountsPath); errors.Is(err, os.ErrNotExist) {
err := os.MkdirAll(s.server.BackupAccountsPath, os.ModePerm)
if err != nil {
return false, err
}
}
file, _ := json.MarshalIndent(accounts, "", " ")
err = ioutil.WriteFile(s.server.BackupAccountsPath+"/"+backupName(), file, 0644)
if err != nil {
return false, err
}
return true, nil
}
func (s *server) RestoreAccounts(a ...IPacket) {
for _, v := range a {
packets, err := v.getPackets()
if err != nil {
fmt.Println(err)
}
for _, p := range packets {
xml, err := CreateXMLString(*p)
if err != nil {
fmt.Println(err)
}
res, err := s.CreateAccount(xml)
if err != nil {
fmt.Println(err)
}
fmt.Println(res.Mail.Action.Result.Status)
fmt.Println(res.Mail.Action.Result.ErrorText)
}
}
}
//RestoreMail restores the Maildir from backup directory to the source directory specified on Server struct
func (s *server) RestoreMail() (bool, error) {
dest := s.server.SSHConfig.SSHUser + "@" + s.server.SSHConfig.SSHHost + ":" + s.server.MaildirPath
//fmt.Println(source)
//s.SSHUser+"@"+s.SSHHost+":/root/sync/",
task := grsync.NewTask(
"sync/ververis.eu/test",
dest,
grsync.RsyncOptions{
Delete: true,
},
)
go func() {
for {
state := task.State()
fmt.Printf(
"progress: %.2f / rem. %d / tot. %d / sp. %s \n",
state.Progress,
state.Remain,
state.Total,
state.Speed,
)
<-time.After(time.Second)
return
}
}()
if err := task.Run(); err != nil {
return false, err
}
fmt.Println("well done")
fmt.Println("###LOG###")
fmt.Println(task.Log())
fmt.Println("###END LOG###")
out, err := s.changeOwnership()
if err != nil {
return false, err
}
fmt.Println(out)
return true, nil
}
func CreateXMLString(p types.Packet) (string, error) {
if xmlstring, err := xml.MarshalIndent(p, "", " "); err == nil {
xmlstring = []byte(xml.Header + string(xmlstring))
return string(xmlstring), nil
} else {
return "", err
}
}
//Begin API (plesk specific) functions
//Create a single account passing the payload as xml string
func (s *server) CreateAccount(payloadXML string) (types.Packet, error) {
r := &types.Packet{}
if (ApiKey{}) == *s.apiKey {
err := s.logIn()
if err != nil {
return *r, err
}
}
url := s.server.ApiConfig.ApiHost + "/enterprise/control/agent.php"
method := "GET"
payload := strings.NewReader(payloadXML)
client := &http.Client{}
req, err := http.NewRequest(method, url, payload)
if err != nil {
fmt.Println(err)
return *r, err
}
req.Header.Add("KEY", s.apiKey.Key)
req.Header.Add("Content-Type", "application/xml")
res, err := client.Do(req)
if err != nil {
fmt.Println(err)
return *r, err
}
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
if err != nil {
fmt.Println(err)
return *r, err
}
response := string(body)
err = xml.Unmarshal([]byte(response), r)
if err != nil {
fmt.Println(err)
}
//fmt.Println(response)
return *r, nil
}
func (s *server) logIn() error {
serializedServer := server{}
apiKey := ApiKey{}
//Check file existance
if _, err := os.Stat(serialServerPath); err == nil {
//Load serialized server to check if an API-Key is stored
file, err := ioutil.ReadFile(serialServerPath)
if err != nil {
return err
}
err = json.Unmarshal([]byte(file), &serializedServer)
if err != nil {
return err
}
//If an API-Key exists store set to apiKey variable
if (ApiKey{}) != *serializedServer.apiKey {
apiKey = *serializedServer.apiKey
} else {
apiKey, err = s.callApiKey()
if err != nil {
return err
}
}
} else if errors.Is(err, os.ErrNotExist) {
apiKey, err = s.callApiKey()
if err != nil {
return err
}
}
s.apiKey = &apiKey
fmt.Println("The Api key is: " + s.apiKey.Key)
//fmt.Println(s)
file, _ := json.MarshalIndent(s.apiKey, "", " ")
err := ioutil.WriteFile(serialServerPath, file, 0644)
if err != nil {
return err
}
return nil
}
//Begin Internal supporting functions
func (s *server) MarshalJSON() ([]byte, error) {
marshal, err := json.MarshalIndent(struct {
ApiKey *ApiKey
}{
ApiKey: s.apiKey,
}, "", " ")
if err != nil {
return nil, err
}
return marshal, nil
}
func (s *server) UnmarshalJSON(b []byte) error {
return json.Unmarshal(b, &s.apiKey)
}
func (s *server) getSSHConfig() (*ssh.ClientConfig, error) {
key, err := ioutil.ReadFile(s.server.SSHConfig.LocalPrivateKeyPath)
if err != nil {
fmt.Println("unable to read private key: ", err)
return nil, err
}
// Create the Signer for this private key.
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
fmt.Println("unable to parse private key: ", err)
return nil, err
}
hostKeyCallback, err := kh.New(s.server.SSHConfig.LocalKnownHostPath)
if err != nil {
fmt.Println("could not create hostkeycallback function: ", err)
return nil, err
}
config := &ssh.ClientConfig{
User: s.server.SSHConfig.SSHUser,
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: hostKeyCallback,
}
return config, nil
}
func (s *server) callApiKey() (ApiKey, error) {
apiKey := ApiKey{}
var auth string
//Create basicAuth using user and password
if s.server.ApiConfig.ApiUser != "" && s.server.ApiConfig.Password != "" {
auth = basicAuth(s.server.ApiConfig.ApiUser, s.server.ApiConfig.Password)
} else {
return apiKey, errors.New("credential missing")
}
url := s.server.ApiConfig.ApiHost + "/api/v2/auth/keys"
method := "POST"
payload := strings.NewReader(`{}`)
client := &http.Client{}
req, err := http.NewRequest(method, url, payload)
if err != nil {
fmt.Println(err)
return apiKey, err
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Accept", "application/json")
req.Header.Add("Authorization", "Basic "+auth)
res, err := client.Do(req)
if err != nil {
fmt.Println(err)
return apiKey, err
}
defer res.Body.Close()
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return apiKey, err
}
err = json.Unmarshal([]byte(body), &apiKey)
if err != nil {
return apiKey, err
}
return apiKey, nil
}
//Connects to server over ssh, performs a qurey of all existing accounts and returns a csv string
func (s *server) createAccountBackup() (string, error) {
// A public key may be used to authenticate against the remote
// server by using an unencrypted PEM-encoded private key file.
//
// If you have an encrypted private key, the crypto/x509 package
// can be used to decrypt it.
/*
var hostKey ssh.PublicKey
key, err := ioutil.ReadFile("/Users/mandi/.ssh/id_rsa")
if err != nil {
log.Fatalf("unable to read private key: %v", err)
}
// Create the Signer for this private key.
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
log.Fatalf("unable to parse private key: %v", err)
}
*/
config, err := s.getSSHConfig()
if err != nil {
return "", err
}
conn, err := ssh.Dial("tcp", s.sshAddress, config)
if err != nil {
log.Fatal("Failed to dial: ", err)
return "", err
}
defer conn.Close()
session, err := conn.NewSession()
if err != nil {
fmt.Println(err.Error())
return "", err
}
defer session.Close()
out, err := session.CombinedOutput("/usr/local/psa/admin/sbin/mail_auth_view;")
if err != nil {
fmt.Println("remote execution CMD failed", err)
return "", err
}
return string(out), nil
}
//Creates a name for the backup, based on date and time
func backupName() string {
return string(time.Now().Format("2006-01-02-15:04:05")) + ".json"
}
//Creates the needed xml string for api payload getting an instance of types.Packet struct
/*
Created Packet struct getting the needed parameters
Parameters:
action #string: Use "create"
siteId #int: The ID of the plesk customer account default should be 1
name #string: The email name without the @domain.tld
enabled #bool: Marks the mail address as active default true
passsword #string: The plain text password of the mail account
passwordType #string: The type of the password. Default "plain"
antivirType #string: The type of antivirus behaviour. Default inout
Options:
# inout ativirus for incoming and outgoing mails
# in only incoming mails are checked
# out only outgoing mails are checked
# off antivirus turned off for incoming and outgoing mails
See for details
https://docs.plesk.com/en-US/obsidian/api-rpc/about-xml-api/reference/managing-mail/mail-account-settings.34481/
Returns a types.Packet struct
Example: p := createPacket("create", 1, "test", true, "Test@mail_123", "plain", "inout")
*/
func (s *server) changeOwnership() (string, error) {
config, err := s.getSSHConfig()
if err != nil {
return "", err
}
conn, err := ssh.Dial("tcp", s.sshAddress, config)
if err != nil {
log.Fatal("Failed to dial: ", err)
return "", err
}
defer conn.Close()
session, err := conn.NewSession()
if err != nil {
fmt.Println(err.Error())
return "", err
}
defer session.Close()
//command := "chown -R " + s.MailDirOwner + ":" + s.MailDirGroup + " /var/qmail/mailnames/ververis.eu/test;"
command := "chown -R " + s.server.MailDirOwner + ":" + s.server.MailDirGroup + " " + s.server.MaildirPath + "/test;"
out, err := session.CombinedOutput(command)
if err != nil {
fmt.Println("remote execution CMD failed", err)
return "", err
}
return string(out), nil
}
//End server implementation
func createPacket(siteId int, action ApiAction, name, password string, enabled bool, passwordType PasswordType, antivirType AntivirType) types.Packet {
a := &types.Action{}
a.Filter = types.Filter{
SiteID: siteId,
}
a.Filter.MailName = types.MailName{
Name: name,
Mailbox: &types.MailBox{Enabled: enabled},
Password: &types.Password{Value: password, Type: string(passwordType)},
Antivir: &types.Antivir{Antivir: string(antivirType)},
}
a.XMLName.Local = string(action)
p := types.Packet{
Mail: types.Mail{
Action: a,
},
}
//fmt.Println(CreateXMLString(p))
return p
}
func basicAuth(username, password string) string {
auth := username + ":" + password
return base64.StdEncoding.EncodeToString([]byte(auth))
}