diff --git a/bin/php b/bin/php index 189a66e..ea66671 100755 --- a/bin/php +++ b/bin/php @@ -1,3 +1,4 @@ #!/usr/bin/env bash APP_DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd ) -${APP_DIR}/php/bin/php.sh -c ${APP_DIR}/config/php/php.ini $@ +DATA_DIR=${SNAP_DATA:-/var${APP_DIR}} +${APP_DIR}/php/bin/php.sh -c ${DATA_DIR}/config/php/php.ini "$@" diff --git a/cli/build.sh b/cli/build.sh index c072ac1..c68b65f 100755 --- a/cli/build.sh +++ b/cli/build.sh @@ -2,6 +2,8 @@ DIR=$(cd "$(dirname "$0")" && pwd) cd "$DIR" +go test ./... + OUT_HOOKS=../build/snap/meta/hooks OUT_BIN=../build/snap/bin mkdir -p "$OUT_HOOKS" "$OUT_BIN" diff --git a/cli/go.mod b/cli/go.mod index b583ab1..4aa304e 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -2,15 +2,19 @@ module hooks require ( github.com/spf13/cobra v1.8.0 + github.com/stretchr/testify v1.11.1 github.com/syncloud/golib v1.1.21 go.uber.org/zap v1.26.0 gopkg.in/ini.v1 v1.67.3 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.5 // indirect go.uber.org/multierr v1.11.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) go 1.21 diff --git a/cli/installer/installer.go b/cli/installer/installer.go index 91058a3..1542950 100644 --- a/cli/installer/installer.go +++ b/cli/installer/installer.go @@ -54,6 +54,7 @@ type Installer struct { platformClient *platform.Client database *Database relay *Relay + mailInbound *MailInbound executor *Executor logger *zap.Logger } @@ -77,6 +78,7 @@ func New(logger *zap.Logger) *Installer { platformClient: platformClient, database: NewDatabase(appDir, dataDir, configPath, DbName, DbUser, DbPass, PsqlPort, executor, logger), relay: NewRelay(appDir, configPath, executor, logger), + mailInbound: NewMailInbound(dataDir, platform.NewHttpClient(), logger), executor: executor, logger: logger, } @@ -257,6 +259,9 @@ func (i *Installer) Install() error { if err := i.InitConfig(); err != nil { return err } + if err := i.mailInbound.Register(); err != nil { + return err + } return i.database.Init() } @@ -264,6 +269,9 @@ func (i *Installer) PostRefresh() error { if err := i.InitConfig(); err != nil { return err } + if err := i.mailInbound.Register(); err != nil { + return err + } return i.database.Rebuild() } diff --git a/cli/installer/mail_inbound.go b/cli/installer/mail_inbound.go new file mode 100644 index 0000000..ee44d38 --- /dev/null +++ b/cli/installer/mail_inbound.go @@ -0,0 +1,49 @@ +package installer + +import ( + "fmt" + "io" + "net/http" + "net/url" + "path" + + "github.com/syncloud/golib/platform" + "go.uber.org/zap" +) + +const TunnelSocket = "spool/public/tunnel" + +type MailInbound struct { + dataDir string + client platform.HttpClient + logger *zap.Logger +} + +func NewMailInbound(dataDir string, client platform.HttpClient, logger *zap.Logger) *MailInbound { + return &MailInbound{dataDir: dataDir, client: client, logger: logger} +} + +func (m *MailInbound) SocketPath() string { + return path.Join(m.dataDir, TunnelSocket) +} + +func (m *MailInbound) Register() error { + socket := m.SocketPath() + m.logger.Info("registering the inbound mail socket", zap.String("socket", socket)) + resp, err := m.client.Post("http://unix/mail/inbound/register", + url.Values{"socket": {socket}}) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + m.logger.Info("this platform does not take inbound mail registrations") + return nil + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("unable to register the inbound mail socket: %s %s", + resp.Status, string(body)) + } + return nil +} diff --git a/cli/installer/mail_inbound_test.go b/cli/installer/mail_inbound_test.go new file mode 100644 index 0000000..c80625b --- /dev/null +++ b/cli/installer/mail_inbound_test.go @@ -0,0 +1,57 @@ +package installer + +import ( + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap" +) + +type fakePlatform struct { + status int + posted url.Values + path string +} + +func (f *fakePlatform) Post(path string, values url.Values) (*http.Response, error) { + f.path = path + f.posted = values + return &http.Response{ + StatusCode: f.status, + Status: http.StatusText(f.status), + Body: io.NopCloser(strings.NewReader("")), + }, nil +} + +func (f *fakePlatform) Get(_ string) (*http.Response, error) { + return nil, nil +} + +func TestMailInbound_RegistersTheSocket(t *testing.T) { + platform := &fakePlatform{status: http.StatusOK} + inbound := NewMailInbound("/var/snap/mail/current", platform, zap.NewNop()) + + assert.NoError(t, inbound.Register()) + + assert.Equal(t, "http://unix/mail/inbound/register", platform.path) + assert.Equal(t, "/var/snap/mail/current/spool/public/tunnel", + platform.posted.Get("socket")) +} + +func TestMailInbound_OlderPlatformWithoutTheEndpointIsNotAFailure(t *testing.T) { + platform := &fakePlatform{status: http.StatusNotFound} + inbound := NewMailInbound("/var/snap/mail/current", platform, zap.NewNop()) + + assert.NoError(t, inbound.Register()) +} + +func TestMailInbound_ReportsOtherFailures(t *testing.T) { + platform := &fakePlatform{status: http.StatusInternalServerError} + inbound := NewMailInbound("/var/snap/mail/current", platform, zap.NewNop()) + + assert.Error(t, inbound.Register()) +} diff --git a/config/postfix/master.cf b/config/postfix/master.cf index 2d2601d..2fb4279 100644 --- a/config/postfix/master.cf +++ b/config/postfix/master.cf @@ -14,6 +14,16 @@ smtp inet n - n - - smtpd #smtpd pass - - n - - smtpd #dnsblog unix - - n - 0 dnsblog #tlsproxy unix - - n - 0 tlsproxy +tunnel unix n - n - - smtpd + -o syslog_name=postfix/tunnel + -o mynetworks= + -o smtpd_relay_restrictions=reject_unauth_destination + -o smtpd_recipient_restrictions= + -o smtpd_sasl_auth_enable=no + -o smtpd_tls_security_level=none + -o smtpd_milters= + -o non_smtpd_milters= + submission inet n - n - - smtpd -o syslog_name=postfix/submission # -o smtpd_tls_security_level=encrypt diff --git a/test/test.py b/test/test.py index 5c35c83..04ec0bf 100644 --- a/test/test.py +++ b/test/test.py @@ -279,3 +279,101 @@ def assert_relayed(domain): messages = faker_messages(domain) assert len(messages) > 0, 'relay received nothing' return messages + + +TUNNEL_SOCKET = '/var/snap/mail/current/spool/public/tunnel' + + +PHP = '/snap/mail/current/bin/php' + + +def tunnel_smtp(device, conversation): + code = ( + '') + + send('RCPT TO:<{0}@{1}>'.format(device_user, domain)) + + send('DATA') + + write('Subject: tunnel-delivery') + + write('') + + write('through the tunnel') + + send('.') + + send('QUIT'))) + assert 'queued' in out, out + + after = retry_func( + lambda: assert_arrived(app_domain, device_user, device_password, before), + message='tunnel delivery', retries=20, sleep=3) + assert after > before + + +def assert_arrived(app_domain, device_user, device_password, before): + count = get_message_count(app_domain, device_user, device_password) + assert count > before, 'nothing arrived through the tunnel' + return count + + +def test_tunnel_refuses_to_relay_elsewhere(device): + out = tunnel_smtp(device, ( + send('EHLO tunnel.test') + + send('MAIL FROM:') + + send('RCPT TO:') + + send('QUIT'))) + assert '554' in out or '550' in out, out + + +def latest_message(app_domain, device_user, device_password): + server = imaplib.IMAP4_SSL(app_domain, ssl_context=(SSLContext(ssl.PROTOCOL_TLS))) + server.login(device_user, device_password) + server.select('inbox') + _, data = server.search(None, 'SUBJECT', '"tunnel-delivery"') + ids = data[0].split() + assert ids, 'the tunnel delivered message is not in the mailbox' + _, fetched = server.fetch(ids[-1], '(RFC822)') + server.logout() + return fetched[0][1].decode('utf-8', 'replace') + + +def test_tunnel_does_not_sign_incoming_mail(app_domain, domain, device_user, device_password): + message = latest_message(app_domain, device_user, device_password) + signed_by_us = 'd={0}'.format(domain) + assert signed_by_us not in message, message[:2000]