Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion bin/php
Original file line number Diff line number Diff line change
@@ -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 "$@"
2 changes: 2 additions & 0 deletions cli/build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions cli/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 8 additions & 0 deletions cli/installer/installer.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ type Installer struct {
platformClient *platform.Client
database *Database
relay *Relay
mailInbound *MailInbound
executor *Executor
logger *zap.Logger
}
Expand All @@ -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,
}
Expand Down Expand Up @@ -257,13 +259,19 @@ 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()
}

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()
}

Expand Down
49 changes: 49 additions & 0 deletions cli/installer/mail_inbound.go
Original file line number Diff line number Diff line change
@@ -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
}
57 changes: 57 additions & 0 deletions cli/installer/mail_inbound_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
10 changes: 10 additions & 0 deletions config/postfix/master.cf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions test/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
'<?php\n'
'function reply($f) {{\n'
' $out = "";\n'
' while (($l = fgets($f)) !== false) {{\n'
' $out .= $l;\n'
' if (strlen($l) < 4 || $l[3] != "-") break;\n'
' }}\n'
' return $out;\n'
'}}\n'
'$f = stream_socket_client("unix://{0}", $errno, $error, 20);\n'
'if (!$f) {{ echo "connect failed: " . $error; exit(1); }}\n'
'stream_set_timeout($f, 20);\n'
'echo reply($f);\n'
'{1}'
).format(TUNNEL_SOCKET, conversation)
escaped = code.replace('$', '\\$').replace('"', '\\"')
return device.run_ssh(
"{0} <<'PHPEOF'\n{1}PHPEOF".format(PHP, escaped), throw=False)


def send(line):
return 'fwrite($f, "{0}\\r\\n");\necho reply($f);\n'.format(line)


def write(line):
return 'fwrite($f, "{0}\\r\\n");\n'.format(line)


def test_tunnel_listens_on_a_socket_not_a_port(device):
listening = device.run_ssh("ss -lnt || true", throw=False)
assert ':10025' not in listening, listening

socket = device.run_ssh("ls -l {0}".format(TUNNEL_SOCKET), throw=False)
assert 'srw' in socket, socket


def test_tunnel_delivers_to_a_local_user(device, domain, device_user, app_domain,
device_password):
before = get_message_count(app_domain, device_user, device_password)
out = tunnel_smtp(device, (
send('EHLO tunnel.test')
+ send('MAIL FROM:<outside@example.com>')
+ 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:<outside@example.com>')
+ send('RCPT TO:<victim@example.com>')
+ 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]