add kerio format support
This commit is contained in:
@@ -1,9 +1,11 @@
|
||||
package csvimport
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -52,3 +54,110 @@ func Parse(r io.Reader) ([]Row, error) {
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Kerio Connect exports users as a semicolon-separated file whose first line is
|
||||
// a header. Only these columns matter; the rest (quotas, last login, …) is
|
||||
// ignored. The file carries no domain — the caller supplies it.
|
||||
const (
|
||||
kerioColName = 0 // login without the domain part
|
||||
kerioColDescription = 2 // Kerio keeps the plaintext password here
|
||||
kerioColEnable = 3 // "Yes" / "No"
|
||||
kerioMinColumns = 4
|
||||
)
|
||||
|
||||
// domainRe accepts a bare DNS domain: labels of alphanumerics/hyphens with at
|
||||
// least one dot and no scheme, user part, or whitespace.
|
||||
var domainRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$`)
|
||||
|
||||
func normalizeDomain(domain string) (string, error) {
|
||||
d := strings.ToLower(strings.TrimSpace(domain))
|
||||
if d == "" {
|
||||
return "", fmt.Errorf("domain is required")
|
||||
}
|
||||
if !domainRe.MatchString(d) {
|
||||
return "", fmt.Errorf("invalid domain %q", domain)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// skipBOM consumes a leading UTF-8 byte-order mark, which Kerio writes into its
|
||||
// exports and encoding/csv would otherwise glue onto the first header field.
|
||||
func skipBOM(br *bufio.Reader) error {
|
||||
b, err := br.Peek(3)
|
||||
if err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
if len(b) == 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF {
|
||||
_, _ = br.Discard(3)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseKerio reads a Kerio Connect user export and maps every enabled, non-admin
|
||||
// account onto both sides of a migration: the login and password are identical
|
||||
// on source and destination, only the server differs. Rows for disabled accounts
|
||||
// and the built-in admin are skipped.
|
||||
func ParseKerio(r io.Reader, domain string) ([]Row, error) {
|
||||
d, err := normalizeDomain(domain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
br := bufio.NewReader(r)
|
||||
if err := skipBOM(br); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cr := csv.NewReader(br)
|
||||
cr.Comma = ';'
|
||||
cr.FieldsPerRecord = -1 // проверяем сами
|
||||
cr.LazyQuotes = true // FullName нередко содержит одиночную кавычку
|
||||
|
||||
header, err := cr.Read()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot read header: %w", err)
|
||||
}
|
||||
if len(header) < kerioMinColumns || !strings.EqualFold(strings.TrimSpace(header[kerioColName]), "Name") {
|
||||
return nil, fmt.Errorf("not a Kerio export: expected a header starting with Name;FullName;Description;Enable")
|
||||
}
|
||||
|
||||
var rows []Row
|
||||
seen := map[string]bool{}
|
||||
for {
|
||||
rec, err := cr.Read()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
line, _ := cr.FieldPos(0)
|
||||
if len(rec) == 1 && strings.TrimSpace(rec[0]) == "" {
|
||||
continue
|
||||
}
|
||||
if len(rec) < kerioMinColumns {
|
||||
return nil, fmt.Errorf("line %d: expected at least %d columns, got %d", line, kerioMinColumns, len(rec))
|
||||
}
|
||||
name := strings.ToLower(strings.TrimSpace(rec[kerioColName]))
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("line %d: Name is empty", line)
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(rec[kerioColEnable]), "Yes") || name == "admin" {
|
||||
continue
|
||||
}
|
||||
pass := strings.TrimSpace(rec[kerioColDescription])
|
||||
if pass == "" {
|
||||
return nil, fmt.Errorf("line %d: no password in the Description column for %q", line, name)
|
||||
}
|
||||
if seen[name] {
|
||||
return nil, fmt.Errorf("line %d: duplicate Name %q", line, name)
|
||||
}
|
||||
seen[name] = true
|
||||
login := name + "@" + d
|
||||
rows = append(rows, Row{SrcLogin: login, SrcPass: pass, DstLogin: login, DstPass: pass})
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("no enabled accounts found in the export")
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user