package policy import ( "fmt" "log" "net" "net/mail" "net/netip" "os" "postern/internal/db" "go.starlark.net/starlark" "go.starlark.net/starlarkstruct" "go.starlark.net/syntax" ) type RelayConfig struct { Host string Port int Username string Password string } type Engine struct { Listeners []ListenerConfig UsersModule *UsersModule globals starlark.StringDict thread *starlark.Thread } type ListenerConfig struct { Address netip.AddrPort Role string TLSCertFile string TLSKeyFile string } type Address struct { Address, Name, Raw string } func (a Address) String() string { return a.Raw } func (a Address) Type() string { return "Address" } func (a Address) Freeze() {} func (a Address) Truth() starlark.Bool { return true } func (a Address) Hash() (uint32, error) { return 0, nil } func (a Address) Attr(name string) (starlark.Value, error) { switch name { case "address": return starlark.String(a.Address), nil case "name": return starlark.String(a.Name), nil case "raw": return starlark.String(a.Raw), nil } return nil, nil } func (a Address) AttrNames() []string { return []string{"address", "name", "raw"} } type MessageContext struct { HeaderFrom Address HeaderTo Address Subject string Headers mail.Header Size int64 } func (m MessageContext) String() string { return "" } func (m MessageContext) Type() string { return "MessageContext" } func (m MessageContext) Freeze() { m.HeaderFrom.Freeze() m.HeaderTo.Freeze() } func (m MessageContext) Truth() starlark.Bool { return true } func (m MessageContext) Hash() (uint32, error) { return 0, nil } func (m MessageContext) Attr(name string) (starlark.Value, error) { switch name { case "header_from": return m.HeaderFrom, nil case "header_to": return m.HeaderTo, nil case "subject": return starlark.String(m.Subject), nil case "size": return starlark.MakeInt64(m.Size), nil } return nil, nil } func (m MessageContext) AttrNames() []string { return []string{"header_from", "subject", "size"} } // MDA implements starlark.Value and starlark.HasAttrs // so it can be exposed as the `mda` global object in the script. type MDA struct { parsedListeners []ListenerConfig } func (m *MDA) String() string { return "" } func (m *MDA) Type() string { return "module" } func (m *MDA) Freeze() {} func (m *MDA) Truth() starlark.Bool { return true } func (m *MDA) Hash() (uint32, error) { return 0, nil } // Attr resolves mda.listen, mda.accept, mda.reject, mda.deliver_to, and mda.quarantine when called in Starlark func (m *MDA) Attr(name string) (starlark.Value, error) { switch name { case "listen": return starlark.NewBuiltin("listen", m.mdaListen), nil } return nil, nil } func (m *MDA) AttrNames() []string { return []string{"listen"} } type Conn struct { RemoteAddr starlark.String ClientIP starlark.String Meta *starlarkstruct.Struct } func (c Conn) String() string { return "" } func (c Conn) Type() string { return "Conn" } func (c Conn) Freeze() { c.Meta.Freeze() } func (c Conn) Truth() starlark.Bool { return true } func (c Conn) Hash() (uint32, error) { return 0, nil } func (c Conn) Attr(name string) (starlark.Value, error) { switch name { case "remote_addr": return c.RemoteAddr, nil case "client_ip": return c.ClientIP, nil case "meta": return c.Meta, nil } return nil, nil } func (c Conn) AttrNames() []string { return []string{"remote_addr", "client_ip", "meta"} } func ConnToStarlark(conn net.Conn) Conn { remoteAddr := conn.RemoteAddr().String() var clientIP string if tcpAddr, ok := conn.RemoteAddr().(*net.TCPAddr); ok { clientIP = tcpAddr.IP.String() } else { clientIP = remoteAddr } return Conn{ RemoteAddr: starlark.String(remoteAddr), ClientIP: starlark.String(clientIP), Meta: starlarkstruct.FromStringDict(starlark.None, starlark.StringDict{}), } } // mdaListen handles mda.listen(address="...", role="...") func (m *MDA) mdaListen(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { var address, role, tlsCertFile, tlsKeyFile string // starlark.UnpackArgs maps Starlark arguments to Go variables safely err := starlark.UnpackArgs( b.Name(), args, kwargs, "address", &address, "role", &role, "tls_cert_file", &tlsCertFile, "tls_key_file", &tlsKeyFile) if err != nil { return nil, err } addrPort, err := netip.ParseAddrPort(address) if err != nil { return nil, err } // Save to our Go state m.parsedListeners = append(m.parsedListeners, ListenerConfig{ Address: addrPort, Role: role, TLSCertFile: tlsCertFile, TLSKeyFile: tlsKeyFile, }) return starlark.None, nil } func NewEngine(policyFile string, database *db.DB) (Engine, error) { script, err := os.ReadFile(policyFile) if err != nil { log.Fatal(err) } // Create an 'os' module containing our getenv function osModule := &starlarkstruct.Module{ Name: "os", Members: starlark.StringDict{ "getenv": starlark.NewBuiltin("getenv", starlarkGetenv), }, } // Create the users module usersModule := NewUsersModule(database) // Expose the "os", "mda", and "users" modules to the Starlark global environment m := &MDA{ parsedListeners: make([]ListenerConfig, 0), } predeclared := starlark.StringDict{ "mda": m, "os": osModule, "users": usersModule, } // Create a Starlark thread. We customize the Print function // so the Starlark `print()` built-in logs cleanly in Go. thread := &starlark.Thread{ Name: "mda-main", Print: func(_ *starlark.Thread, msg string) { fmt.Printf("[init.star]: %s\n", msg) }, } // 1. Evaluate the script globals, err := starlark.ExecFileOptions(&syntax.FileOptions{}, thread, "policy.star", script, predeclared) if err != nil { return Engine{}, err } // 2. Call the init() function initVal, ok := globals["init"] if !ok { return Engine{}, fmt.Errorf("init not found in policy") } _, err = starlark.Call(thread, initVal, nil, nil) if err != nil { return Engine{}, err } engine := Engine{ Listeners: m.parsedListeners, UsersModule: usersModule, globals: globals, thread: thread, } return engine, err } func (e *Engine) OnMessageSubmit(user User, msg MessageContext) (RelayConfig, error) { hookVal, ok := e.globals["on_message_submit"] if !ok { return RelayConfig{}, nil } result, err := starlark.Call(e.thread, hookVal, starlark.Tuple{user, msg}, nil) if err != nil { return RelayConfig{}, err } if result == starlark.None { return RelayConfig{}, nil } dict, ok := result.(*starlark.Dict) if !ok { return RelayConfig{}, fmt.Errorf("on_message_submit must return a dict, got %s", result.Type()) } relay := RelayConfig{ Host: dictString(dict, "host"), Port: dictInt(dict, "port"), Username: dictString(dict, "username"), Password: dictString(dict, "password"), } return relay, nil } func dictString(dict *starlark.Dict, key string) string { v, _, err := dict.Get(starlark.String(key)) if err != nil || v == starlark.None { return "" } if s, ok := v.(starlark.String); ok { return string(s) } return "" } func dictInt(dict *starlark.Dict, key string) int { v, _, err := dict.Get(starlark.String(key)) if err != nil || v == starlark.None { return 0 } if i, ok := v.(starlark.Int); ok { if n, ok := i.Int64(); ok { return int(n) } } return 0 } func (e *Engine) OnMessageDeliver(conn Conn, user User, msg MessageContext) (string, error) { defaultMailbox := "INBOX" hookVal, ok := e.globals["on_message_deliver"] if !ok { return defaultMailbox, nil } result, err := starlark.Call(e.thread, hookVal, starlark.Tuple{conn, user, msg}, nil) if err != nil { return defaultMailbox, err } mailbox := defaultMailbox if result != starlark.None { if s, ok := result.(starlark.String); ok { mailbox = string(s) } } return mailbox, nil } func (e *Engine) OnMessageMoved(user User, msg MessageContext, sourceFolder, destFolder string) error { hookVal, ok := e.globals["on_message_moved"] if !ok { return nil } _, err := starlark.Call(e.thread, hookVal, starlark.Tuple{user, msg, starlark.String(sourceFolder), starlark.String(destFolder)}, nil) return err } // MessageContextFromEnvelope builds a policy.MessageContext from the envelope // fields of a stored message. The header_from address is parsed into an // Address; if parsing fails the raw string is preserved. func MessageContextFromEnvelope(from, subject string, size int64) MessageContext { fromAddr, err := ParseAddress(from) if err != nil || from == "" { fromAddr = Address{Raw: from, Address: from} } return MessageContext{ HeaderFrom: fromAddr, Subject: subject, Size: size, } } func ParseAddress(email string) (Address, error) { a, err := mail.ParseAddress(email) if err != nil { return Address{}, err } addr := Address{ Raw: email, Address: a.Address, Name: a.Name, } return addr, nil } // starlarkGetenv implements Python's os.getenv("KEY", "default") func starlarkGetenv(_ *starlark.Thread, b *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { var key string var def starlark.Value = starlark.None if err := starlark.UnpackArgs(b.Name(), args, kwargs, "key", &key, "default?", &def); err != nil { return nil, err } if val, exists := os.LookupEnv(key); exists { return starlark.String(val), nil } return def, nil }