From 4d502579a805e054a5edbe19d133a759e09a3ec7 Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Thu, 6 Aug 2026 20:43:21 +0100 Subject: [PATCH 1/7] Tunnel inbound mail to the device redirect terminates port 25 and forwards each message down the frp tunnel, so the device has to ask for a proxy on the port it was allocated. frpc gains a tcp proxy alongside the https one, named -smtp to match the suffix redirect strips when it attributes traffic and authorises the port. The tunnel is no longer all or nothing. Enable and Disable became Apply, which renders whichever proxies the settings call for: web when the traffic relay is on, mail when the mail relay is on and a port has been handed out, either without the other. That matters because the common case for mail is a device with a public address whose isp blocks 25 outbound and inbound; it needs a tunnel for mail alone and had no way to get one. The port comes back on the domain update, which already runs on both paths: the access page calls Update and the mail relay switch calls Sync. Update applies the tunnel a second time afterwards, since the port only exists once redirect has answered, and without that a device turning the mail relay on would wait for some unrelated change before its mail proxy appeared. A response that cannot be read is logged and ignored rather than failing the update, because the address change has already happened and undoing it over a port would be worse. Bringing the tunnel up now waits for every proxy it asked for, not just the web one, so a half connected tunnel is a failure rather than a device that looks fine and quietly receives no mail. Local port 10025 is the contract with the mail app: postfix listens there for mail arriving through the tunnel, separately from its own port 25. --- backend/access/external_address.go | 21 ++-- backend/access/external_address_test.go | 57 +++++++--- backend/access/relay_client.go | 121 +++++++++++++++------ backend/access/relay_client_test.go | 133 ++++++++++++++++++++++-- backend/config/system_config.go | 4 + backend/config/user_config.go | 12 +++ backend/redirect/model.go | 6 ++ backend/redirect/redirect.go | 28 ++++- backend/redirect/redirect_test.go | 5 + config/frp/frpc.toml | 10 +- 10 files changed, 323 insertions(+), 74 deletions(-) diff --git a/backend/access/external_address.go b/backend/access/external_address.go index 816ff85b..b264d71d 100644 --- a/backend/access/external_address.go +++ b/backend/access/external_address.go @@ -29,8 +29,7 @@ type Redirect interface { } type Relay interface { - Enable() error - Disable() error + Apply(relayEnabled bool) error } type Trigger interface { @@ -78,14 +77,8 @@ func (a *ExternalAddress) Update(request model.Access) error { a.logger.Info(fmt.Sprintf("update relay: %v, ipv4 enabled: %v, ipv4 public: %v, ipv6 enabled: %v", request.RelayEnabled, request.Ipv4Enabled, request.Ipv4Public, request.Ipv6Enabled)) - if request.RelayEnabled { - if err := a.relay.Enable(); err != nil { - return err - } - } else { - if err := a.relay.Disable(); err != nil { - return err - } + if err := a.relay.Apply(request.RelayEnabled); err != nil { + return err } ipv4 := request.Ipv4 @@ -152,6 +145,12 @@ func (a *ExternalAddress) Update(request model.Access) error { a.userConfig.SetIpv6Enabled(request.Ipv6Enabled) a.userConfig.SetPublicPort(request.AccessPort) + // redirect hands out the smtp port on the update above, so the tunnel may + // only now be able to carry mail + if err := a.relay.Apply(request.RelayEnabled); err != nil { + return err + } + a.trigger.Trigger() return nil @@ -171,5 +170,5 @@ func (a *ExternalAddress) Sync() error { return err } } - return nil + return a.relay.Apply(a.userConfig.IsRelayEnabled()) } diff --git a/backend/access/external_address_test.go b/backend/access/external_address_test.go index bb3baddc..8ecb80c3 100644 --- a/backend/access/external_address_test.go +++ b/backend/access/external_address_test.go @@ -31,15 +31,16 @@ type RedirectStub struct { type RelayStub struct { enabled bool disabled bool + applied int } -func (r *RelayStub) Enable() error { - r.enabled = true - return nil -} - -func (r *RelayStub) Disable() error { - r.disabled = true +func (r *RelayStub) Apply(relayEnabled bool) error { + r.applied++ + if relayEnabled { + r.enabled = true + } else { + r.disabled = true + } return nil } @@ -98,23 +99,19 @@ func (u *ExternalAddressUserConfigStub) SetPublicPort(port *int) { } func (u *ExternalAddressUserConfigStub) GetPublicIp() *string { - //TODO implement me - panic("implement me") + return u.publicIp } func (u *ExternalAddressUserConfigStub) GetPublicPort() *int { - //TODO implement me - panic("implement me") + return nil } func (u *ExternalAddressUserConfigStub) IsIpv6Enabled() bool { - //TODO implement me - panic("implement me") + return false } func (u *ExternalAddressUserConfigStub) IsIpv4Public() bool { - //TODO implement me - panic("implement me") + return false } func (u *ExternalAddressUserConfigStub) IsRelayEnabled() bool { @@ -125,8 +122,7 @@ func (u *ExternalAddressUserConfigStub) SetRelayEnabled(enabled bool) { } func (u *ExternalAddressUserConfigStub) IsIpv4Enabled() bool { - //TODO implement me - panic("implement me") + return true } func TestExternalAddress_UpdateWithIpv4(t *testing.T) { @@ -224,3 +220,30 @@ func TestExternalAddress_Ipv4Private_NoProbe(t *testing.T) { assert.Nil(t, config.publicIp) assert.Equal(t, 0, len(probe.probed)) } + +func TestExternalAddress_UpdateAppliesTheTunnelAgainAfterTheAddressUpdate(t *testing.T) { + relay := &RelayStub{} + access := New(NewPoptProbeStub(), &ExternalAddressUserConfigStub{}, &RedirectStub{}, relay, + &TriggerStub{}, &NetworkInfoStub{publicIPv4: "2.2.2.2"}, log.Default()) + + err := access.Update(model.Access{Ipv4Enabled: true, Ipv4Public: false}) + + assert.Nil(t, err) + // redirect hands out the smtp port during the update, so the tunnel has to + // be reapplied afterwards or a device turning the mail relay on would not + // get its mail proxy until something else changed + assert.Equal(t, 2, relay.applied) +} + +func TestExternalAddress_SyncAppliesTheTunnel(t *testing.T) { + relay := &RelayStub{} + access := New(NewPoptProbeStub(), &ExternalAddressUserConfigStub{}, &RedirectStub{}, relay, + &TriggerStub{}, &NetworkInfoStub{publicIPv4: "2.2.2.2"}, log.Default()) + + err := access.Sync() + + assert.Nil(t, err) + // switching the mail relay on goes through Sync, and that is the only way + // a device with the traffic relay off ever gets a tunnel + assert.Equal(t, 1, relay.applied) +} diff --git a/backend/access/relay_client.go b/backend/access/relay_client.go index dd767dc5..80cc88ee 100644 --- a/backend/access/relay_client.go +++ b/backend/access/relay_client.go @@ -21,6 +21,10 @@ const ( relayAdminSocket = "frpc-admin.sock" relayAdminUrl = "http://unix/api/status" relayConnectAttempts = 30 + + // must match the suffix redirect strips when it attributes tunnel traffic + // and authorises the proxy's port + SmtpProxySuffix = "-smtp" ) type RelayControl interface { @@ -33,16 +37,22 @@ type RelaySystemConfig interface { } type frpcConfig struct { - Server string - Token string - AdminSocket string - Domain string - LocalPort int + Server string + Token string + AdminSocket string + Domain string + Web bool + LocalPort int + Mail bool + MailLocalPort int + SmtpPort int } type RelayUserConfig interface { GetDeviceDomain() string GetDomainUpdateToken() *string + IsMailRelayEnabled() bool + GetMailSmtpPort() *int } type RelayRedirectConfig interface { @@ -56,6 +66,8 @@ type RelayClient struct { redirect RelayRedirectConfig client *http.Client logger *zap.Logger + + connectAttempts int } func NewRelayClient(control RelayControl, systemConfig RelaySystemConfig, userConfig RelayUserConfig, redirect RelayRedirectConfig, client *http.Client, logger *zap.Logger) *RelayClient { @@ -66,6 +78,8 @@ func NewRelayClient(control RelayControl, systemConfig RelaySystemConfig, userCo redirect: redirect, client: client, logger: logger, + + connectAttempts: relayConnectAttempts, } } @@ -89,7 +103,20 @@ func (c *RelayClient) adminSocketPath() string { return filepath.Join(c.systemConfig.DataDir(), relayAdminSocket) } -func (c *RelayClient) Enable() error { +// Apply brings the tunnel to the state the device's settings ask for. The web +// proxy carries app traffic when the relay is on; the smtp proxy carries +// inbound mail when the mail relay is on and redirect has handed out a port. +// Either can be present without the other, so a device that only wants mail +// still gets a tunnel. +func (c *RelayClient) Apply(relayEnabled bool) error { + mailEnabled := c.userConfig.IsMailRelayEnabled() + smtpPort := c.userConfig.GetMailSmtpPort() + mail := mailEnabled && smtpPort != nil + + if !relayEnabled && !mail { + return c.Disable() + } + domain := c.userConfig.GetDeviceDomain() token := c.userConfig.GetDomainUpdateToken() if token == nil { @@ -100,31 +127,50 @@ func (c *RelayClient) Enable() error { if err != nil { return err } + settings := frpcConfig{ + Server: server, + Token: *token, + AdminSocket: c.adminSocketPath(), + Domain: domain, + Web: relayEnabled, + LocalPort: config.WebAccessPort, + Mail: mail, + MailLocalPort: config.MailInboundPort, + } + if mail { + settings.SmtpPort = *smtpPort + } var content bytes.Buffer - err = tmpl.Execute(&content, frpcConfig{ - Server: server, - Token: *token, - AdminSocket: c.adminSocketPath(), - Domain: domain, - LocalPort: config.WebAccessPort, - }) - if err != nil { + if err := tmpl.Execute(&content, settings); err != nil { return err } - if c.currentConfig() == content.String() && c.proxyRunning(domain) { - c.logger.Info("relay already connected, skipping restart", zap.String("domain", domain)) + expected := c.expectedProxies(domain, relayEnabled, mail) + if c.currentConfig() == content.String() && c.proxiesRunning(expected) { + c.logger.Info("relay already connected, skipping restart", zap.Strings("proxies", expected)) return nil } - c.logger.Info("enabling relay", zap.String("server", server), zap.String("domain", domain)) + c.logger.Info("applying relay", + zap.String("server", server), zap.Strings("proxies", expected)) if err := os.WriteFile(c.configPath(), content.Bytes(), 0644); err != nil { return err } if err := c.control.RestartService(RelayService); err != nil { return err } - return c.waitConnected(domain) + return c.waitConnected(expected) +} + +func (c *RelayClient) expectedProxies(domain string, web bool, mail bool) []string { + var proxies []string + if web { + proxies = append(proxies, domain) + } + if mail { + proxies = append(proxies, domain+SmtpProxySuffix) + } + return proxies } func (c *RelayClient) currentConfig() string { @@ -135,38 +181,49 @@ func (c *RelayClient) currentConfig() string { return string(content) } -func (c *RelayClient) waitConnected(domain string) error { - for attempt := 0; attempt < relayConnectAttempts; attempt++ { - if c.proxyRunning(domain) { - c.logger.Info("relay tunnel connected", zap.String("domain", domain)) +func (c *RelayClient) waitConnected(proxies []string) error { + for attempt := 0; attempt < c.connectAttempts; attempt++ { + if c.proxiesRunning(proxies) { + c.logger.Info("relay tunnel connected", zap.Strings("proxies", proxies)) return nil } time.Sleep(time.Second) } - return fmt.Errorf("relay tunnel did not come up for %s", domain) + return fmt.Errorf("relay tunnel did not come up for %v", proxies) +} + +func (c *RelayClient) proxiesRunning(proxies []string) bool { + running := c.runningProxies() + for _, name := range proxies { + if !running[name] { + return false + } + } + return true } -func (c *RelayClient) proxyRunning(domain string) bool { +func (c *RelayClient) runningProxies() map[string]bool { + result := map[string]bool{} resp, err := c.client.Get(relayAdminUrl) if err != nil { - return false + return result } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return false + return result } var status map[string][]relayProxyStatus if err := json.NewDecoder(resp.Body).Decode(&status); err != nil { - return false + return result } - for _, proxies := range status { - for _, p := range proxies { - if p.Name == domain && p.Status == "running" { - return true + for _, group := range status { + for _, p := range group { + if p.Status == "running" { + result[p.Name] = true } } } - return false + return result } func (c *RelayClient) Disable() error { diff --git a/backend/access/relay_client_test.go b/backend/access/relay_client_test.go index 6e9c7041..16e6963d 100644 --- a/backend/access/relay_client_test.go +++ b/backend/access/relay_client_test.go @@ -36,8 +36,18 @@ func (c *relaySystemConfigStub) ConfigDir() string { } type relayUserConfigStub struct { - domain string - token *string + mailRelay bool + smtpPort *int + domain string + token *string +} + +func (s *relayUserConfigStub) IsMailRelayEnabled() bool { + return s.mailRelay +} + +func (s *relayUserConfigStub) GetMailSmtpPort() *int { + return s.smtpPort } func (c *relayUserConfigStub) GetDeviceDomain() string { @@ -73,9 +83,9 @@ func TestRelayClient_EnableWritesConfigAndRestartsAndVerifies(t *testing.T) { dir := t.TempDir() token := "tok123" control := &relayControlStub{} - client := NewRelayClient(control, &relaySystemConfigStub{dir, "../../config"}, &relayUserConfigStub{"name.syncloud.it", &token}, &relayRedirectStub{"syncloud.it"}, relayRunningClient("name.syncloud.it"), zap.NewNop()) + client := NewRelayClient(control, &relaySystemConfigStub{dir, "../../config"}, &relayUserConfigStub{domain: "name.syncloud.it", token: &token}, &relayRedirectStub{"syncloud.it"}, relayRunningClient("name.syncloud.it"), zap.NewNop()) - err := client.Enable() + err := client.Apply(true) assert.Nil(t, err) content, err := os.ReadFile(filepath.Join(dir, "frpc.toml")) @@ -93,17 +103,17 @@ func TestRelayClient_EnableIdempotentSkipsRestartWhenConnected(t *testing.T) { dir := t.TempDir() token := "tok123" control := &relayControlStub{} - client := NewRelayClient(control, &relaySystemConfigStub{dir, "../../config"}, &relayUserConfigStub{"name.syncloud.it", &token}, &relayRedirectStub{"syncloud.it"}, relayRunningClient("name.syncloud.it"), zap.NewNop()) + client := NewRelayClient(control, &relaySystemConfigStub{dir, "../../config"}, &relayUserConfigStub{domain: "name.syncloud.it", token: &token}, &relayRedirectStub{"syncloud.it"}, relayRunningClient("name.syncloud.it"), zap.NewNop()) - assert.Nil(t, client.Enable()) - assert.Nil(t, client.Enable()) + assert.Nil(t, client.Apply(true)) + assert.Nil(t, client.Apply(true)) assert.Equal(t, []string{RelayService}, control.restarted) } func TestRelayClient_EnableWithoutTokenFails(t *testing.T) { - client := NewRelayClient(&relayControlStub{}, &relaySystemConfigStub{t.TempDir(), "../../config"}, &relayUserConfigStub{"name.syncloud.it", nil}, &relayRedirectStub{"syncloud.it"}, relayRunningClient("name.syncloud.it"), zap.NewNop()) - assert.NotNil(t, client.Enable()) + client := NewRelayClient(&relayControlStub{}, &relaySystemConfigStub{t.TempDir(), "../../config"}, &relayUserConfigStub{domain: "name.syncloud.it"}, &relayRedirectStub{"syncloud.it"}, relayRunningClient("name.syncloud.it"), zap.NewNop()) + assert.NotNil(t, client.Apply(true)) } func TestRelayClient_DisableRemovesConfigAndRestartsToIdle(t *testing.T) { @@ -111,7 +121,7 @@ func TestRelayClient_DisableRemovesConfigAndRestartsToIdle(t *testing.T) { path := filepath.Join(dir, "frpc.toml") assert.Nil(t, os.WriteFile(path, []byte("x"), 0644)) control := &relayControlStub{} - client := NewRelayClient(control, &relaySystemConfigStub{dir, "../../config"}, &relayUserConfigStub{"name.syncloud.it", nil}, &relayRedirectStub{"syncloud.it"}, relayRunningClient("name.syncloud.it"), zap.NewNop()) + client := NewRelayClient(control, &relaySystemConfigStub{dir, "../../config"}, &relayUserConfigStub{domain: "name.syncloud.it"}, &relayRedirectStub{"syncloud.it"}, relayRunningClient("name.syncloud.it"), zap.NewNop()) err := client.Disable() assert.Nil(t, err) @@ -122,7 +132,108 @@ func TestRelayClient_DisableRemovesConfigAndRestartsToIdle(t *testing.T) { func TestRelayClient_DisableWithoutConfigIsNoop(t *testing.T) { control := &relayControlStub{} - client := NewRelayClient(control, &relaySystemConfigStub{t.TempDir(), "../../config"}, &relayUserConfigStub{"name.syncloud.it", nil}, &relayRedirectStub{"syncloud.it"}, relayRunningClient("name.syncloud.it"), zap.NewNop()) + client := NewRelayClient(control, &relaySystemConfigStub{t.TempDir(), "../../config"}, &relayUserConfigStub{domain: "name.syncloud.it"}, &relayRedirectStub{"syncloud.it"}, relayRunningClient("name.syncloud.it"), zap.NewNop()) assert.Nil(t, client.Disable()) assert.Empty(t, control.restarted) } + +func relayRunningProxies(names ...string) *http.Client { + var parts []string + for _, n := range names { + parts = append(parts, fmt.Sprintf(`{"name":"%s","status":"running"}`, n)) + } + body := fmt.Sprintf(`{"all":[%s]}`, strings.Join(parts, ",")) + return &http.Client{Transport: relayStatusTransport{body}} +} + +func mailClient(t *testing.T, dir string, relay bool, mail bool, port *int, running ...string) (*RelayClient, *relayControlStub) { + t.Helper() + token := "tok123" + control := &relayControlStub{} + user := &relayUserConfigStub{domain: "name.syncloud.it", token: &token, mailRelay: mail, smtpPort: port} + client := NewRelayClient(control, &relaySystemConfigStub{dir, "../../config"}, user, + &relayRedirectStub{"syncloud.it"}, relayRunningProxies(running...), zap.NewNop()) + return client, control +} + +func TestRelayClient_MailOnlyTunnelHasNoWebProxy(t *testing.T) { + dir := t.TempDir() + port := 20005 + client, control := mailClient(t, dir, false, true, &port, "name.syncloud.it-smtp") + + assert.Nil(t, client.Apply(false)) + + s := readConfig(t, dir) + assert.NotContains(t, s, `type = "https"`) + assert.Contains(t, s, `name = "name.syncloud.it-smtp"`) + assert.Contains(t, s, `type = "tcp"`) + assert.Contains(t, s, "remotePort = 20005") + assert.Contains(t, s, "localPort = 10025") + assert.Equal(t, []string{RelayService}, control.restarted) +} + +func TestRelayClient_BothProxiesWhenRelayAndMailAreOn(t *testing.T) { + dir := t.TempDir() + port := 20005 + client, _ := mailClient(t, dir, true, true, &port, "name.syncloud.it", "name.syncloud.it-smtp") + + assert.Nil(t, client.Apply(true)) + + s := readConfig(t, dir) + assert.Contains(t, s, `type = "https"`) + assert.Contains(t, s, `type = "tcp"`) + assert.Contains(t, s, "remotePort = 20005") +} + +func TestRelayClient_NoMailProxyWithoutAnAllocatedPort(t *testing.T) { + dir := t.TempDir() + client, _ := mailClient(t, dir, true, true, nil, "name.syncloud.it") + + assert.Nil(t, client.Apply(true)) + + s := readConfig(t, dir) + assert.Contains(t, s, `type = "https"`) + assert.NotContains(t, s, `type = "tcp"`) +} + +func TestRelayClient_MailRelayOnButNoPortAndNoRelayIsIdle(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "frpc.toml") + assert.Nil(t, os.WriteFile(path, []byte("x"), 0644)) + client, control := mailClient(t, dir, false, true, nil) + + assert.Nil(t, client.Apply(false)) + + _, statErr := os.Stat(path) + assert.True(t, os.IsNotExist(statErr)) + assert.Equal(t, []string{RelayService}, control.restarted) +} + +func TestRelayClient_BothOffRemovesTheTunnel(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "frpc.toml") + assert.Nil(t, os.WriteFile(path, []byte("x"), 0644)) + client, _ := mailClient(t, dir, false, false, nil) + + assert.Nil(t, client.Apply(false)) + + _, statErr := os.Stat(path) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestRelayClient_WaitsForBothProxiesBeforeSucceeding(t *testing.T) { + dir := t.TempDir() + port := 20005 + // only the web proxy comes up, the mail one never does + client, _ := mailClient(t, dir, true, true, &port, "name.syncloud.it") + client.connectAttempts = 2 + + assert.NotNil(t, client.Apply(true)) +} + +func readConfig(t *testing.T, dir string) string { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "frpc.toml")) + assert.Nil(t, err) + return string(content) +} diff --git a/backend/config/system_config.go b/backend/config/system_config.go index 0c97ea0b..bd0c2f06 100644 --- a/backend/config/system_config.go +++ b/backend/config/system_config.go @@ -6,6 +6,10 @@ import ( ) const WebAccessPort = 443 + +// the loopback port the mail app's postfix listens on for mail arriving +// through the relay tunnel +const MailInboundPort = 10025 const WebProtocol = "https" type SystemConfig struct { diff --git a/backend/config/user_config.go b/backend/config/user_config.go index 41780ff3..a3a079ee 100644 --- a/backend/config/user_config.go +++ b/backend/config/user_config.go @@ -70,6 +70,18 @@ func (c *UserConfig) SetMailRelayEnabled(enabled bool) { c.db.UpsertBool("platform.mail_relay_enabled", enabled) } +func (c *UserConfig) GetMailSmtpPort() *int { + return c.db.GetOrNilInt("platform.mail_smtp_port") +} + +func (c *UserConfig) SetMailSmtpPort(port *int) { + if port == nil { + c.db.Delete("platform.mail_smtp_port") + } else { + c.db.Upsert("platform.mail_smtp_port", strconv.Itoa(*port)) + } +} + func (c *UserConfig) IsRedirectEnabled() bool { return c.db.GetBool("platform.redirect_enabled", false) } diff --git a/backend/redirect/model.go b/backend/redirect/model.go index b3d67c78..39316540 100644 --- a/backend/redirect/model.go +++ b/backend/redirect/model.go @@ -73,6 +73,11 @@ type FreeDomainAcquireResponse struct { Data *Domain `json:"data,omitempty"` } +type FreeDomainUpdateResponse struct { + Success bool `json:"success"` + Data *Domain `json:"data,omitempty"` +} + type Domain struct { Name string `json:"name,omitempty"` Ip *string `json:"ip,omitempty"` @@ -88,5 +93,6 @@ type Domain struct { PlatformVersion *string `json:"platform_version,omitempty"` WebProtocol *string `json:"web_protocol,omitempty"` WebPort *int `json:"web_port,omitempty"` + SmtpPort *int `json:"smtp_port,omitempty"` WebLocalPort *int `json:"web_local_port,omitempty"` } diff --git a/backend/redirect/redirect.go b/backend/redirect/redirect.go index 41fbd7fa..d80140d0 100644 --- a/backend/redirect/redirect.go +++ b/backend/redirect/redirect.go @@ -17,6 +17,7 @@ type UserConfig interface { GetDomainUpdateToken() *string GetDkimKey() *string IsMailRelayEnabled() bool + SetMailSmtpPort(port *int) } type RedirectConfig interface { @@ -182,8 +183,31 @@ func (r *Service) Update(relay bool, ipv4 *string, port *int, ipv4Enabled bool, } url := fmt.Sprintf("%s/%s", r.redirect.ApiUrl(), "domain/update") - _, err = r.postAndCheck(url, request) - return err + body, err := r.postAndCheck(url, request) + if err != nil { + return err + } + return r.saveSmtpPort(body) +} + +// redirect allocates an inbound mail port per device and returns it with the +// domain, so the tunnel knows which port to ask frps for +func (r *Service) saveSmtpPort(body *[]byte) error { + if body == nil { + return nil + } + var response FreeDomainUpdateResponse + if err := json.Unmarshal(*body, &response); err != nil { + // the port only decides whether inbound mail can be tunnelled; the + // address update itself has already succeeded and must not be undone + log.Printf("cannot read the smtp port from the domain update: %v", err) + return nil + } + if response.Data == nil { + return nil + } + r.userConfig.SetMailSmtpPort(response.Data.SmtpPort) + return nil } func (r *Service) postAndCheck(url string, request interface{}) (*[]byte, error) { diff --git a/backend/redirect/redirect_test.go b/backend/redirect/redirect_test.go index d8bf0831..b04ced62 100644 --- a/backend/redirect/redirect_test.go +++ b/backend/redirect/redirect_test.go @@ -13,6 +13,11 @@ import ( ) type UserConfigStub struct { + smtpPort *int +} + +func (u *UserConfigStub) SetMailSmtpPort(port *int) { + u.smtpPort = port } func (u *UserConfigStub) GetDomainUpdateToken() *string { diff --git a/config/frp/frpc.toml b/config/frp/frpc.toml index 5e8550e2..83fc7a71 100644 --- a/config/frp/frpc.toml +++ b/config/frp/frpc.toml @@ -3,10 +3,18 @@ serverPort = 443 transport.tls.enable = true metadatas.token = "{{ .Token }}" webServer.unixSocket = "{{ .AdminSocket }}" - +{{ if .Web }} [[proxies]] name = "{{ .Domain }}" type = "https" customDomains = ["{{ .Domain }}", "*.{{ .Domain }}"] localIP = "127.0.0.1" localPort = {{ .LocalPort }} +{{ end }}{{ if .Mail }} +[[proxies]] +name = "{{ .Domain }}-smtp" +type = "tcp" +localIP = "127.0.0.1" +localPort = {{ .MailLocalPort }} +remotePort = {{ .SmtpPort }} +{{ end }} From 9aba2c550d1f402414126d152bba50f66ec8984b Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Thu, 6 Aug 2026 22:58:00 +0100 Subject: [PATCH 2/7] Ask for the mail tunnel by name, not by a port of its own Every device was allocated a port on the relay so mailin had something to dial, which meant a range with a ceiling, ports that were never reclaimed, and the number travelling back through the domain update into the device's config. frp routes the web proxy by name over one shared port already. tcpmux does the same for raw tcp, matching the name in an http CONNECT that mailin sends itself, so the mail proxy can share a port the same way. The device asks for its tunnel by its own domain, which is also what the auth plugin already checks for the web proxy. That removes the smtp port from the update response, from the device config and from this side entirely. --- backend/access/relay_client.go | 14 ++---- backend/access/relay_client_test.go | 67 ++++++++--------------------- backend/config/user_config.go | 12 ------ backend/redirect/model.go | 6 --- backend/redirect/redirect.go | 28 +----------- backend/redirect/redirect_test.go | 5 --- config/frp/frpc.toml | 5 ++- 7 files changed, 27 insertions(+), 110 deletions(-) diff --git a/backend/access/relay_client.go b/backend/access/relay_client.go index 80cc88ee..5c68ab72 100644 --- a/backend/access/relay_client.go +++ b/backend/access/relay_client.go @@ -45,14 +45,12 @@ type frpcConfig struct { LocalPort int Mail bool MailLocalPort int - SmtpPort int } type RelayUserConfig interface { GetDeviceDomain() string GetDomainUpdateToken() *string IsMailRelayEnabled() bool - GetMailSmtpPort() *int } type RelayRedirectConfig interface { @@ -105,13 +103,10 @@ func (c *RelayClient) adminSocketPath() string { // Apply brings the tunnel to the state the device's settings ask for. The web // proxy carries app traffic when the relay is on; the smtp proxy carries -// inbound mail when the mail relay is on and redirect has handed out a port. -// Either can be present without the other, so a device that only wants mail -// still gets a tunnel. +// inbound mail when the mail relay is on. Either can be present without the +// other, so a device that only wants mail still gets a tunnel. func (c *RelayClient) Apply(relayEnabled bool) error { - mailEnabled := c.userConfig.IsMailRelayEnabled() - smtpPort := c.userConfig.GetMailSmtpPort() - mail := mailEnabled && smtpPort != nil + mail := c.userConfig.IsMailRelayEnabled() if !relayEnabled && !mail { return c.Disable() @@ -137,9 +132,6 @@ func (c *RelayClient) Apply(relayEnabled bool) error { Mail: mail, MailLocalPort: config.MailInboundPort, } - if mail { - settings.SmtpPort = *smtpPort - } var content bytes.Buffer if err := tmpl.Execute(&content, settings); err != nil { return err diff --git a/backend/access/relay_client_test.go b/backend/access/relay_client_test.go index 16e6963d..26724af8 100644 --- a/backend/access/relay_client_test.go +++ b/backend/access/relay_client_test.go @@ -37,7 +37,6 @@ func (c *relaySystemConfigStub) ConfigDir() string { type relayUserConfigStub struct { mailRelay bool - smtpPort *int domain string token *string } @@ -46,10 +45,6 @@ func (s *relayUserConfigStub) IsMailRelayEnabled() bool { return s.mailRelay } -func (s *relayUserConfigStub) GetMailSmtpPort() *int { - return s.smtpPort -} - func (c *relayUserConfigStub) GetDeviceDomain() string { return c.domain } @@ -146,11 +141,11 @@ func relayRunningProxies(names ...string) *http.Client { return &http.Client{Transport: relayStatusTransport{body}} } -func mailClient(t *testing.T, dir string, relay bool, mail bool, port *int, running ...string) (*RelayClient, *relayControlStub) { +func mailClient(t *testing.T, dir string, relay bool, mail bool, running ...string) (*RelayClient, *relayControlStub) { t.Helper() token := "tok123" control := &relayControlStub{} - user := &relayUserConfigStub{domain: "name.syncloud.it", token: &token, mailRelay: mail, smtpPort: port} + user := &relayUserConfigStub{domain: "name.syncloud.it", token: &token, mailRelay: mail} client := NewRelayClient(control, &relaySystemConfigStub{dir, "../../config"}, user, &relayRedirectStub{"syncloud.it"}, relayRunningProxies(running...), zap.NewNop()) return client, control @@ -158,62 +153,38 @@ func mailClient(t *testing.T, dir string, relay bool, mail bool, port *int, runn func TestRelayClient_MailOnlyTunnelHasNoWebProxy(t *testing.T) { dir := t.TempDir() - port := 20005 - client, control := mailClient(t, dir, false, true, &port, "name.syncloud.it-smtp") + client, control := mailClient(t, dir, false, true, "name.syncloud.it-smtp") assert.Nil(t, client.Apply(false)) - s := readConfig(t, dir) + s, err := readConfig(dir) + assert.Nil(t, err) assert.NotContains(t, s, `type = "https"`) assert.Contains(t, s, `name = "name.syncloud.it-smtp"`) - assert.Contains(t, s, `type = "tcp"`) - assert.Contains(t, s, "remotePort = 20005") + assert.Contains(t, s, `type = "tcpmux"`) + assert.Contains(t, s, `multiplexer = "httpconnect"`) + assert.Contains(t, s, `customDomains = ["name.syncloud.it"]`) assert.Contains(t, s, "localPort = 10025") assert.Equal(t, []string{RelayService}, control.restarted) } func TestRelayClient_BothProxiesWhenRelayAndMailAreOn(t *testing.T) { dir := t.TempDir() - port := 20005 - client, _ := mailClient(t, dir, true, true, &port, "name.syncloud.it", "name.syncloud.it-smtp") + client, _ := mailClient(t, dir, true, true, "name.syncloud.it", "name.syncloud.it-smtp") assert.Nil(t, client.Apply(true)) - s := readConfig(t, dir) - assert.Contains(t, s, `type = "https"`) - assert.Contains(t, s, `type = "tcp"`) - assert.Contains(t, s, "remotePort = 20005") -} - -func TestRelayClient_NoMailProxyWithoutAnAllocatedPort(t *testing.T) { - dir := t.TempDir() - client, _ := mailClient(t, dir, true, true, nil, "name.syncloud.it") - - assert.Nil(t, client.Apply(true)) - - s := readConfig(t, dir) + s, err := readConfig(dir) + assert.Nil(t, err) assert.Contains(t, s, `type = "https"`) - assert.NotContains(t, s, `type = "tcp"`) -} - -func TestRelayClient_MailRelayOnButNoPortAndNoRelayIsIdle(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "frpc.toml") - assert.Nil(t, os.WriteFile(path, []byte("x"), 0644)) - client, control := mailClient(t, dir, false, true, nil) - - assert.Nil(t, client.Apply(false)) - - _, statErr := os.Stat(path) - assert.True(t, os.IsNotExist(statErr)) - assert.Equal(t, []string{RelayService}, control.restarted) + assert.Contains(t, s, `type = "tcpmux"`) } func TestRelayClient_BothOffRemovesTheTunnel(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "frpc.toml") assert.Nil(t, os.WriteFile(path, []byte("x"), 0644)) - client, _ := mailClient(t, dir, false, false, nil) + client, _ := mailClient(t, dir, false, false) assert.Nil(t, client.Apply(false)) @@ -223,17 +194,17 @@ func TestRelayClient_BothOffRemovesTheTunnel(t *testing.T) { func TestRelayClient_WaitsForBothProxiesBeforeSucceeding(t *testing.T) { dir := t.TempDir() - port := 20005 // only the web proxy comes up, the mail one never does - client, _ := mailClient(t, dir, true, true, &port, "name.syncloud.it") + client, _ := mailClient(t, dir, true, true, "name.syncloud.it") client.connectAttempts = 2 assert.NotNil(t, client.Apply(true)) } -func readConfig(t *testing.T, dir string) string { - t.Helper() +func readConfig(dir string) (string, error) { content, err := os.ReadFile(filepath.Join(dir, "frpc.toml")) - assert.Nil(t, err) - return string(content) + if err != nil { + return "", err + } + return string(content), nil } diff --git a/backend/config/user_config.go b/backend/config/user_config.go index a3a079ee..41780ff3 100644 --- a/backend/config/user_config.go +++ b/backend/config/user_config.go @@ -70,18 +70,6 @@ func (c *UserConfig) SetMailRelayEnabled(enabled bool) { c.db.UpsertBool("platform.mail_relay_enabled", enabled) } -func (c *UserConfig) GetMailSmtpPort() *int { - return c.db.GetOrNilInt("platform.mail_smtp_port") -} - -func (c *UserConfig) SetMailSmtpPort(port *int) { - if port == nil { - c.db.Delete("platform.mail_smtp_port") - } else { - c.db.Upsert("platform.mail_smtp_port", strconv.Itoa(*port)) - } -} - func (c *UserConfig) IsRedirectEnabled() bool { return c.db.GetBool("platform.redirect_enabled", false) } diff --git a/backend/redirect/model.go b/backend/redirect/model.go index 39316540..b3d67c78 100644 --- a/backend/redirect/model.go +++ b/backend/redirect/model.go @@ -73,11 +73,6 @@ type FreeDomainAcquireResponse struct { Data *Domain `json:"data,omitempty"` } -type FreeDomainUpdateResponse struct { - Success bool `json:"success"` - Data *Domain `json:"data,omitempty"` -} - type Domain struct { Name string `json:"name,omitempty"` Ip *string `json:"ip,omitempty"` @@ -93,6 +88,5 @@ type Domain struct { PlatformVersion *string `json:"platform_version,omitempty"` WebProtocol *string `json:"web_protocol,omitempty"` WebPort *int `json:"web_port,omitempty"` - SmtpPort *int `json:"smtp_port,omitempty"` WebLocalPort *int `json:"web_local_port,omitempty"` } diff --git a/backend/redirect/redirect.go b/backend/redirect/redirect.go index d80140d0..41fbd7fa 100644 --- a/backend/redirect/redirect.go +++ b/backend/redirect/redirect.go @@ -17,7 +17,6 @@ type UserConfig interface { GetDomainUpdateToken() *string GetDkimKey() *string IsMailRelayEnabled() bool - SetMailSmtpPort(port *int) } type RedirectConfig interface { @@ -183,31 +182,8 @@ func (r *Service) Update(relay bool, ipv4 *string, port *int, ipv4Enabled bool, } url := fmt.Sprintf("%s/%s", r.redirect.ApiUrl(), "domain/update") - body, err := r.postAndCheck(url, request) - if err != nil { - return err - } - return r.saveSmtpPort(body) -} - -// redirect allocates an inbound mail port per device and returns it with the -// domain, so the tunnel knows which port to ask frps for -func (r *Service) saveSmtpPort(body *[]byte) error { - if body == nil { - return nil - } - var response FreeDomainUpdateResponse - if err := json.Unmarshal(*body, &response); err != nil { - // the port only decides whether inbound mail can be tunnelled; the - // address update itself has already succeeded and must not be undone - log.Printf("cannot read the smtp port from the domain update: %v", err) - return nil - } - if response.Data == nil { - return nil - } - r.userConfig.SetMailSmtpPort(response.Data.SmtpPort) - return nil + _, err = r.postAndCheck(url, request) + return err } func (r *Service) postAndCheck(url string, request interface{}) (*[]byte, error) { diff --git a/backend/redirect/redirect_test.go b/backend/redirect/redirect_test.go index b04ced62..d8bf0831 100644 --- a/backend/redirect/redirect_test.go +++ b/backend/redirect/redirect_test.go @@ -13,11 +13,6 @@ import ( ) type UserConfigStub struct { - smtpPort *int -} - -func (u *UserConfigStub) SetMailSmtpPort(port *int) { - u.smtpPort = port } func (u *UserConfigStub) GetDomainUpdateToken() *string { diff --git a/config/frp/frpc.toml b/config/frp/frpc.toml index 83fc7a71..f9173318 100644 --- a/config/frp/frpc.toml +++ b/config/frp/frpc.toml @@ -13,8 +13,9 @@ localPort = {{ .LocalPort }} {{ end }}{{ if .Mail }} [[proxies]] name = "{{ .Domain }}-smtp" -type = "tcp" +type = "tcpmux" +multiplexer = "httpconnect" +customDomains = ["{{ .Domain }}"] localIP = "127.0.0.1" localPort = {{ .MailLocalPort }} -remotePort = {{ .SmtpPort }} {{ end }} From e7ef8b9ba6e8566aac1f665162961c95e8ccccab Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Sat, 8 Aug 2026 17:44:07 +0100 Subject: [PATCH 3/7] Open the smtp tunnel with the relay rather than the mail relay Inbound mail arrives at redirect because relaying traffic pointed the domain there, so the tunnel that carries it belongs to the same switch. The mail relay keeps its own meaning for outbound submission. Both proxies now stand or fall together, so the template no longer has to decide which of them to write. --- backend/access/relay_client.go | 25 +++------------ backend/access/relay_client_test.go | 48 +++++++++-------------------- config/frp/frpc.toml | 5 ++- 3 files changed, 20 insertions(+), 58 deletions(-) diff --git a/backend/access/relay_client.go b/backend/access/relay_client.go index 5c68ab72..9f6c47c1 100644 --- a/backend/access/relay_client.go +++ b/backend/access/relay_client.go @@ -41,16 +41,13 @@ type frpcConfig struct { Token string AdminSocket string Domain string - Web bool LocalPort int - Mail bool MailLocalPort int } type RelayUserConfig interface { GetDeviceDomain() string GetDomainUpdateToken() *string - IsMailRelayEnabled() bool } type RelayRedirectConfig interface { @@ -102,13 +99,8 @@ func (c *RelayClient) adminSocketPath() string { } // Apply brings the tunnel to the state the device's settings ask for. The web -// proxy carries app traffic when the relay is on; the smtp proxy carries -// inbound mail when the mail relay is on. Either can be present without the -// other, so a device that only wants mail still gets a tunnel. func (c *RelayClient) Apply(relayEnabled bool) error { - mail := c.userConfig.IsMailRelayEnabled() - - if !relayEnabled && !mail { + if !relayEnabled { return c.Disable() } @@ -127,9 +119,7 @@ func (c *RelayClient) Apply(relayEnabled bool) error { Token: *token, AdminSocket: c.adminSocketPath(), Domain: domain, - Web: relayEnabled, LocalPort: config.WebAccessPort, - Mail: mail, MailLocalPort: config.MailInboundPort, } var content bytes.Buffer @@ -137,7 +127,7 @@ func (c *RelayClient) Apply(relayEnabled bool) error { return err } - expected := c.expectedProxies(domain, relayEnabled, mail) + expected := c.expectedProxies(domain) if c.currentConfig() == content.String() && c.proxiesRunning(expected) { c.logger.Info("relay already connected, skipping restart", zap.Strings("proxies", expected)) return nil @@ -154,15 +144,8 @@ func (c *RelayClient) Apply(relayEnabled bool) error { return c.waitConnected(expected) } -func (c *RelayClient) expectedProxies(domain string, web bool, mail bool) []string { - var proxies []string - if web { - proxies = append(proxies, domain) - } - if mail { - proxies = append(proxies, domain+SmtpProxySuffix) - } - return proxies +func (c *RelayClient) expectedProxies(domain string) []string { + return []string{domain, domain + SmtpProxySuffix} } func (c *RelayClient) currentConfig() string { diff --git a/backend/access/relay_client_test.go b/backend/access/relay_client_test.go index 26724af8..f39a2f51 100644 --- a/backend/access/relay_client_test.go +++ b/backend/access/relay_client_test.go @@ -69,16 +69,11 @@ func (t relayStatusTransport) RoundTrip(_ *http.Request) (*http.Response, error) return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(t.body)), Header: make(http.Header)}, nil } -func relayRunningClient(domain string) *http.Client { - body := fmt.Sprintf(`{"https":[{"name":"%s","status":"running"}]}`, domain) - return &http.Client{Transport: relayStatusTransport{body}} -} - func TestRelayClient_EnableWritesConfigAndRestartsAndVerifies(t *testing.T) { dir := t.TempDir() token := "tok123" control := &relayControlStub{} - client := NewRelayClient(control, &relaySystemConfigStub{dir, "../../config"}, &relayUserConfigStub{domain: "name.syncloud.it", token: &token}, &relayRedirectStub{"syncloud.it"}, relayRunningClient("name.syncloud.it"), zap.NewNop()) + client := NewRelayClient(control, &relaySystemConfigStub{dir, "../../config"}, &relayUserConfigStub{domain: "name.syncloud.it", token: &token}, &relayRedirectStub{"syncloud.it"}, relayRunningProxies("name.syncloud.it", "name.syncloud.it-smtp"), zap.NewNop()) err := client.Apply(true) assert.Nil(t, err) @@ -98,7 +93,7 @@ func TestRelayClient_EnableIdempotentSkipsRestartWhenConnected(t *testing.T) { dir := t.TempDir() token := "tok123" control := &relayControlStub{} - client := NewRelayClient(control, &relaySystemConfigStub{dir, "../../config"}, &relayUserConfigStub{domain: "name.syncloud.it", token: &token}, &relayRedirectStub{"syncloud.it"}, relayRunningClient("name.syncloud.it"), zap.NewNop()) + client := NewRelayClient(control, &relaySystemConfigStub{dir, "../../config"}, &relayUserConfigStub{domain: "name.syncloud.it", token: &token}, &relayRedirectStub{"syncloud.it"}, relayRunningProxies("name.syncloud.it", "name.syncloud.it-smtp"), zap.NewNop()) assert.Nil(t, client.Apply(true)) assert.Nil(t, client.Apply(true)) @@ -107,7 +102,7 @@ func TestRelayClient_EnableIdempotentSkipsRestartWhenConnected(t *testing.T) { } func TestRelayClient_EnableWithoutTokenFails(t *testing.T) { - client := NewRelayClient(&relayControlStub{}, &relaySystemConfigStub{t.TempDir(), "../../config"}, &relayUserConfigStub{domain: "name.syncloud.it"}, &relayRedirectStub{"syncloud.it"}, relayRunningClient("name.syncloud.it"), zap.NewNop()) + client := NewRelayClient(&relayControlStub{}, &relaySystemConfigStub{t.TempDir(), "../../config"}, &relayUserConfigStub{domain: "name.syncloud.it"}, &relayRedirectStub{"syncloud.it"}, relayRunningProxies("name.syncloud.it", "name.syncloud.it-smtp"), zap.NewNop()) assert.NotNil(t, client.Apply(true)) } @@ -116,7 +111,7 @@ func TestRelayClient_DisableRemovesConfigAndRestartsToIdle(t *testing.T) { path := filepath.Join(dir, "frpc.toml") assert.Nil(t, os.WriteFile(path, []byte("x"), 0644)) control := &relayControlStub{} - client := NewRelayClient(control, &relaySystemConfigStub{dir, "../../config"}, &relayUserConfigStub{domain: "name.syncloud.it"}, &relayRedirectStub{"syncloud.it"}, relayRunningClient("name.syncloud.it"), zap.NewNop()) + client := NewRelayClient(control, &relaySystemConfigStub{dir, "../../config"}, &relayUserConfigStub{domain: "name.syncloud.it"}, &relayRedirectStub{"syncloud.it"}, relayRunningProxies("name.syncloud.it", "name.syncloud.it-smtp"), zap.NewNop()) err := client.Disable() assert.Nil(t, err) @@ -127,7 +122,7 @@ func TestRelayClient_DisableRemovesConfigAndRestartsToIdle(t *testing.T) { func TestRelayClient_DisableWithoutConfigIsNoop(t *testing.T) { control := &relayControlStub{} - client := NewRelayClient(control, &relaySystemConfigStub{t.TempDir(), "../../config"}, &relayUserConfigStub{domain: "name.syncloud.it"}, &relayRedirectStub{"syncloud.it"}, relayRunningClient("name.syncloud.it"), zap.NewNop()) + client := NewRelayClient(control, &relaySystemConfigStub{t.TempDir(), "../../config"}, &relayUserConfigStub{domain: "name.syncloud.it"}, &relayRedirectStub{"syncloud.it"}, relayRunningProxies("name.syncloud.it", "name.syncloud.it-smtp"), zap.NewNop()) assert.Nil(t, client.Disable()) assert.Empty(t, control.restarted) } @@ -141,50 +136,36 @@ func relayRunningProxies(names ...string) *http.Client { return &http.Client{Transport: relayStatusTransport{body}} } -func mailClient(t *testing.T, dir string, relay bool, mail bool, running ...string) (*RelayClient, *relayControlStub) { +func mailClient(t *testing.T, dir string, running ...string) (*RelayClient, *relayControlStub) { t.Helper() token := "tok123" control := &relayControlStub{} - user := &relayUserConfigStub{domain: "name.syncloud.it", token: &token, mailRelay: mail} + user := &relayUserConfigStub{domain: "name.syncloud.it", token: &token} client := NewRelayClient(control, &relaySystemConfigStub{dir, "../../config"}, user, &relayRedirectStub{"syncloud.it"}, relayRunningProxies(running...), zap.NewNop()) return client, control } -func TestRelayClient_MailOnlyTunnelHasNoWebProxy(t *testing.T) { +func TestRelayClient_RelayOnOpensWebAndMailProxies(t *testing.T) { dir := t.TempDir() - client, control := mailClient(t, dir, false, true, "name.syncloud.it-smtp") + client, _ := mailClient(t, dir, "name.syncloud.it", "name.syncloud.it-smtp") - assert.Nil(t, client.Apply(false)) + assert.Nil(t, client.Apply(true)) s, err := readConfig(dir) assert.Nil(t, err) - assert.NotContains(t, s, `type = "https"`) + assert.Contains(t, s, `type = "https"`) assert.Contains(t, s, `name = "name.syncloud.it-smtp"`) assert.Contains(t, s, `type = "tcpmux"`) assert.Contains(t, s, `multiplexer = "httpconnect"`) - assert.Contains(t, s, `customDomains = ["name.syncloud.it"]`) assert.Contains(t, s, "localPort = 10025") - assert.Equal(t, []string{RelayService}, control.restarted) -} - -func TestRelayClient_BothProxiesWhenRelayAndMailAreOn(t *testing.T) { - dir := t.TempDir() - client, _ := mailClient(t, dir, true, true, "name.syncloud.it", "name.syncloud.it-smtp") - - assert.Nil(t, client.Apply(true)) - - s, err := readConfig(dir) - assert.Nil(t, err) - assert.Contains(t, s, `type = "https"`) - assert.Contains(t, s, `type = "tcpmux"`) } -func TestRelayClient_BothOffRemovesTheTunnel(t *testing.T) { +func TestRelayClient_RelayOffRemovesTheTunnel(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "frpc.toml") assert.Nil(t, os.WriteFile(path, []byte("x"), 0644)) - client, _ := mailClient(t, dir, false, false) + client, _ := mailClient(t, dir) assert.Nil(t, client.Apply(false)) @@ -194,8 +175,7 @@ func TestRelayClient_BothOffRemovesTheTunnel(t *testing.T) { func TestRelayClient_WaitsForBothProxiesBeforeSucceeding(t *testing.T) { dir := t.TempDir() - // only the web proxy comes up, the mail one never does - client, _ := mailClient(t, dir, true, true, "name.syncloud.it") + client, _ := mailClient(t, dir, "name.syncloud.it") client.connectAttempts = 2 assert.NotNil(t, client.Apply(true)) diff --git a/config/frp/frpc.toml b/config/frp/frpc.toml index f9173318..0744045b 100644 --- a/config/frp/frpc.toml +++ b/config/frp/frpc.toml @@ -3,14 +3,14 @@ serverPort = 443 transport.tls.enable = true metadatas.token = "{{ .Token }}" webServer.unixSocket = "{{ .AdminSocket }}" -{{ if .Web }} + [[proxies]] name = "{{ .Domain }}" type = "https" customDomains = ["{{ .Domain }}", "*.{{ .Domain }}"] localIP = "127.0.0.1" localPort = {{ .LocalPort }} -{{ end }}{{ if .Mail }} + [[proxies]] name = "{{ .Domain }}-smtp" type = "tcpmux" @@ -18,4 +18,3 @@ multiplexer = "httpconnect" customDomains = ["{{ .Domain }}"] localIP = "127.0.0.1" localPort = {{ .MailLocalPort }} -{{ end }} From f968160039e2763d83509cbbf0470f7b87a17689 Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Sat, 8 Aug 2026 20:11:17 +0100 Subject: [PATCH 4/7] Give the test relay server a tcpmux port The smtp proxy is a tcpmux one and the frps the ui tests run against only had the vhost https port, so it could never register. Waiting for both proxies made that visible instead of leaving the mail tunnel silently absent. --- .drone.jsonnet | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.drone.jsonnet b/.drone.jsonnet index 1c2f133f..d86e5fee 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -335,7 +335,7 @@ local build(arch, testUI) = [{ name: 'relay.redirect', image: 'snowdreamtech/frps:0.61.1', commands: [ - 'printf "bindPort = 443\nvhostHTTPSPort = 4443\n" > /etc/frp/frps.toml', + 'printf "bindPort = 443\nvhostHTTPSPort = 4443\ntcpMuxHTTPConnectPort = 1337\n" > /etc/frp/frps.toml', '/usr/bin/frps -c /etc/frp/frps.toml', ], }, From 611931304376a6e8d1024f180af27b4a517bec45 Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Sat, 8 Aug 2026 22:49:12 +0100 Subject: [PATCH 5/7] Drop the comments from the relay tunnel changes One was a leftover half sentence and the rest still described handing out a port per device, which stopped being true when the tunnel moved to tcpmux. --- backend/access/external_address.go | 2 -- backend/access/external_address_test.go | 5 ----- backend/access/relay_client.go | 3 --- backend/config/system_config.go | 2 -- 4 files changed, 12 deletions(-) diff --git a/backend/access/external_address.go b/backend/access/external_address.go index b264d71d..1d6dc5d0 100644 --- a/backend/access/external_address.go +++ b/backend/access/external_address.go @@ -145,8 +145,6 @@ func (a *ExternalAddress) Update(request model.Access) error { a.userConfig.SetIpv6Enabled(request.Ipv6Enabled) a.userConfig.SetPublicPort(request.AccessPort) - // redirect hands out the smtp port on the update above, so the tunnel may - // only now be able to carry mail if err := a.relay.Apply(request.RelayEnabled); err != nil { return err } diff --git a/backend/access/external_address_test.go b/backend/access/external_address_test.go index 8ecb80c3..4b6dc276 100644 --- a/backend/access/external_address_test.go +++ b/backend/access/external_address_test.go @@ -229,9 +229,6 @@ func TestExternalAddress_UpdateAppliesTheTunnelAgainAfterTheAddressUpdate(t *tes err := access.Update(model.Access{Ipv4Enabled: true, Ipv4Public: false}) assert.Nil(t, err) - // redirect hands out the smtp port during the update, so the tunnel has to - // be reapplied afterwards or a device turning the mail relay on would not - // get its mail proxy until something else changed assert.Equal(t, 2, relay.applied) } @@ -243,7 +240,5 @@ func TestExternalAddress_SyncAppliesTheTunnel(t *testing.T) { err := access.Sync() assert.Nil(t, err) - // switching the mail relay on goes through Sync, and that is the only way - // a device with the traffic relay off ever gets a tunnel assert.Equal(t, 1, relay.applied) } diff --git a/backend/access/relay_client.go b/backend/access/relay_client.go index 9f6c47c1..6e07392f 100644 --- a/backend/access/relay_client.go +++ b/backend/access/relay_client.go @@ -22,8 +22,6 @@ const ( relayAdminUrl = "http://unix/api/status" relayConnectAttempts = 30 - // must match the suffix redirect strips when it attributes tunnel traffic - // and authorises the proxy's port SmtpProxySuffix = "-smtp" ) @@ -98,7 +96,6 @@ func (c *RelayClient) adminSocketPath() string { return filepath.Join(c.systemConfig.DataDir(), relayAdminSocket) } -// Apply brings the tunnel to the state the device's settings ask for. The web func (c *RelayClient) Apply(relayEnabled bool) error { if !relayEnabled { return c.Disable() diff --git a/backend/config/system_config.go b/backend/config/system_config.go index bd0c2f06..ad16c111 100644 --- a/backend/config/system_config.go +++ b/backend/config/system_config.go @@ -7,8 +7,6 @@ import ( const WebAccessPort = 443 -// the loopback port the mail app's postfix listens on for mail arriving -// through the relay tunnel const MailInboundPort = 10025 const WebProtocol = "https" From 4becdaa2bfb9b88b16348bf3a2c7f61b6d88b738 Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Sun, 9 Aug 2026 00:17:31 +0100 Subject: [PATCH 6/7] Take the mail inbound socket from the app instead of hard coding a port An app registers where it accepts mail from the tunnel and platform forwards there, so the smtp proxy exists only on devices that can actually receive. --- backend/access/relay_client.go | 43 +++++++++++++++++++---------- backend/access/relay_client_test.go | 40 +++++++++++++++++++-------- backend/config/system_config.go | 1 - backend/config/user_config.go | 12 ++++++++ backend/ioc/internal_api.go | 4 ++- backend/rest/api.go | 29 ++++++++++++++++++- config/frp/frpc.toml | 8 ++++-- 7 files changed, 104 insertions(+), 33 deletions(-) diff --git a/backend/access/relay_client.go b/backend/access/relay_client.go index 6e07392f..868fa9e7 100644 --- a/backend/access/relay_client.go +++ b/backend/access/relay_client.go @@ -35,17 +35,18 @@ type RelaySystemConfig interface { } type frpcConfig struct { - Server string - Token string - AdminSocket string - Domain string - LocalPort int - MailLocalPort int + Server string + Token string + AdminSocket string + Domain string + LocalPort int + MailSocket string } type RelayUserConfig interface { GetDeviceDomain() string GetDomainUpdateToken() *string + GetMailInboundSocket() *string } type RelayRedirectConfig interface { @@ -100,6 +101,7 @@ func (c *RelayClient) Apply(relayEnabled bool) error { if !relayEnabled { return c.Disable() } + mailSocket := c.userConfig.GetMailInboundSocket() domain := c.userConfig.GetDeviceDomain() token := c.userConfig.GetDomainUpdateToken() @@ -112,19 +114,19 @@ func (c *RelayClient) Apply(relayEnabled bool) error { return err } settings := frpcConfig{ - Server: server, - Token: *token, - AdminSocket: c.adminSocketPath(), - Domain: domain, - LocalPort: config.WebAccessPort, - MailLocalPort: config.MailInboundPort, + Server: server, + Token: *token, + AdminSocket: c.adminSocketPath(), + Domain: domain, + LocalPort: config.WebAccessPort, + MailSocket: socketPath(mailSocket), } var content bytes.Buffer if err := tmpl.Execute(&content, settings); err != nil { return err } - expected := c.expectedProxies(domain) + expected := c.expectedProxies(domain, mailSocket != nil) if c.currentConfig() == content.String() && c.proxiesRunning(expected) { c.logger.Info("relay already connected, skipping restart", zap.Strings("proxies", expected)) return nil @@ -141,8 +143,19 @@ func (c *RelayClient) Apply(relayEnabled bool) error { return c.waitConnected(expected) } -func (c *RelayClient) expectedProxies(domain string) []string { - return []string{domain, domain + SmtpProxySuffix} +func (c *RelayClient) expectedProxies(domain string, mail bool) []string { + proxies := []string{domain} + if mail { + proxies = append(proxies, domain+SmtpProxySuffix) + } + return proxies +} + +func socketPath(socket *string) string { + if socket == nil { + return "" + } + return *socket } func (c *RelayClient) currentConfig() string { diff --git a/backend/access/relay_client_test.go b/backend/access/relay_client_test.go index f39a2f51..e06838ae 100644 --- a/backend/access/relay_client_test.go +++ b/backend/access/relay_client_test.go @@ -36,13 +36,13 @@ func (c *relaySystemConfigStub) ConfigDir() string { } type relayUserConfigStub struct { - mailRelay bool - domain string - token *string + domain string + token *string + mailSocket *string } -func (s *relayUserConfigStub) IsMailRelayEnabled() bool { - return s.mailRelay +func (c *relayUserConfigStub) GetMailInboundSocket() *string { + return c.mailSocket } func (c *relayUserConfigStub) GetDeviceDomain() string { @@ -136,19 +136,20 @@ func relayRunningProxies(names ...string) *http.Client { return &http.Client{Transport: relayStatusTransport{body}} } -func mailClient(t *testing.T, dir string, running ...string) (*RelayClient, *relayControlStub) { +func mailClient(t *testing.T, dir string, socket *string, running ...string) (*RelayClient, *relayControlStub) { t.Helper() token := "tok123" control := &relayControlStub{} - user := &relayUserConfigStub{domain: "name.syncloud.it", token: &token} + user := &relayUserConfigStub{domain: "name.syncloud.it", token: &token, mailSocket: socket} client := NewRelayClient(control, &relaySystemConfigStub{dir, "../../config"}, user, &relayRedirectStub{"syncloud.it"}, relayRunningProxies(running...), zap.NewNop()) return client, control } -func TestRelayClient_RelayOnOpensWebAndMailProxies(t *testing.T) { +func TestRelayClient_RegisteredMailOpensTheSmtpProxy(t *testing.T) { dir := t.TempDir() - client, _ := mailClient(t, dir, "name.syncloud.it", "name.syncloud.it-smtp") + socket := "/var/snap/mail/common/mail.socket" + client, _ := mailClient(t, dir, &socket, "name.syncloud.it", "name.syncloud.it-smtp") assert.Nil(t, client.Apply(true)) @@ -158,14 +159,28 @@ func TestRelayClient_RelayOnOpensWebAndMailProxies(t *testing.T) { assert.Contains(t, s, `name = "name.syncloud.it-smtp"`) assert.Contains(t, s, `type = "tcpmux"`) assert.Contains(t, s, `multiplexer = "httpconnect"`) - assert.Contains(t, s, "localPort = 10025") + assert.Contains(t, s, `type = "unix_domain_socket"`) + assert.Contains(t, s, `unixPath = "/var/snap/mail/common/mail.socket"`) +} + +func TestRelayClient_WithoutRegisteredMailThereIsNoSmtpProxy(t *testing.T) { + dir := t.TempDir() + client, _ := mailClient(t, dir, nil, "name.syncloud.it") + + assert.Nil(t, client.Apply(true)) + + s, err := readConfig(dir) + assert.Nil(t, err) + assert.Contains(t, s, `type = "https"`) + assert.NotContains(t, s, "-smtp") + assert.NotContains(t, s, `type = "tcpmux"`) } func TestRelayClient_RelayOffRemovesTheTunnel(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "frpc.toml") assert.Nil(t, os.WriteFile(path, []byte("x"), 0644)) - client, _ := mailClient(t, dir) + client, _ := mailClient(t, dir, nil) assert.Nil(t, client.Apply(false)) @@ -175,7 +190,8 @@ func TestRelayClient_RelayOffRemovesTheTunnel(t *testing.T) { func TestRelayClient_WaitsForBothProxiesBeforeSucceeding(t *testing.T) { dir := t.TempDir() - client, _ := mailClient(t, dir, "name.syncloud.it") + socket := "/var/snap/mail/common/mail.socket" + client, _ := mailClient(t, dir, &socket, "name.syncloud.it") client.connectAttempts = 2 assert.NotNil(t, client.Apply(true)) diff --git a/backend/config/system_config.go b/backend/config/system_config.go index ad16c111..6eb75335 100644 --- a/backend/config/system_config.go +++ b/backend/config/system_config.go @@ -7,7 +7,6 @@ import ( const WebAccessPort = 443 -const MailInboundPort = 10025 const WebProtocol = "https" type SystemConfig struct { diff --git a/backend/config/user_config.go b/backend/config/user_config.go index 41780ff3..cfae87ac 100644 --- a/backend/config/user_config.go +++ b/backend/config/user_config.go @@ -117,6 +117,18 @@ func (c *UserConfig) SetDkimKey(key *string) { c.db.Upsert("dkim_key", *key) } } +func (c *UserConfig) GetMailInboundSocket() *string { + return c.db.GetOrNilString("mail.inbound_socket") +} + +func (c *UserConfig) SetMailInboundSocket(socket *string) { + if socket == nil { + c.db.Delete("mail.inbound_socket") + } else { + c.db.Upsert("mail.inbound_socket", *socket) + } +} + func (c *UserConfig) GetDomainUpdateToken() *string { return c.db.GetOrNilString("platform.domain_update_token") } diff --git a/backend/ioc/internal_api.go b/backend/ioc/internal_api.go index 77891e22..168f5312 100644 --- a/backend/ioc/internal_api.go +++ b/backend/ioc/internal_api.go @@ -2,6 +2,7 @@ package ioc import ( "github.com/golobby/container/v3" + "github.com/syncloud/platform/access" "github.com/syncloud/platform/auth" "github.com/syncloud/platform/config" "github.com/syncloud/platform/rest" @@ -21,8 +22,9 @@ func InitInternalApi(userConfig string, systemConfig string, backupDir string, v systemd *systemd.Control, middleware *rest.Middleware, authelia *auth.Authelia, + relay *access.RelayClient, ) *rest.Api { - return rest.NewApi(userConfig, redirect, storage, systemd, middleware, network, address, authelia, logger) + return rest.NewApi(userConfig, redirect, storage, systemd, middleware, network, address, authelia, relay, logger) }) if err != nil { return nil, err diff --git a/backend/rest/api.go b/backend/rest/api.go index 2da54f7b..cd2f95ad 100644 --- a/backend/rest/api.go +++ b/backend/rest/api.go @@ -16,7 +16,9 @@ type DeviceUserConfig interface { Url(app string) string AppDomain(app string) string IsMailRelayEnabled() bool + IsRelayEnabled() bool GetDomainUpdateToken() *string + SetMailInboundSocket(socket *string) } type DeviceRedirect interface { @@ -33,6 +35,10 @@ type Systemd interface { RestartService(service string) error } +type Relay interface { + Apply(relayEnabled bool) error +} + type WebAuth interface { RegisterOIDCClient(id string, redirectURIs []string, requirePkce bool, tokenEndpointAuthMethod string) (string, error) } @@ -46,12 +52,13 @@ type Api struct { network string address string webAuth WebAuth + relay Relay logger *zap.Logger } func NewApi(userConfig DeviceUserConfig, redirect DeviceRedirect, storage Storage, systemd Systemd, middleware *Middleware, network string, address string, - webAuth WebAuth, logger *zap.Logger) *Api { + webAuth WebAuth, relay Relay, logger *zap.Logger) *Api { return &Api{ userConfig: userConfig, redirect: redirect, @@ -61,6 +68,7 @@ func NewApi(userConfig DeviceUserConfig, redirect DeviceRedirect, storage Storag network: network, address: address, webAuth: webAuth, + relay: relay, logger: logger, } } @@ -85,6 +93,8 @@ func (a *Api) Start() error { r.HandleFunc("/app/storage_dir", a.mw.Handle(a.AppStorageDir)).Methods("GET") r.HandleFunc("/user/email", a.mw.Handle(a.UserEmail)).Methods("GET") r.HandleFunc("/oidc/register", a.mw.Handle(a.RegisterOIDCClient)).Methods("POST") + r.HandleFunc("/mail/inbound/register", a.mw.Handle(a.RegisterMailInbound)).Methods("POST") + r.HandleFunc("/mail/inbound/unregister", a.mw.Handle(a.UnregisterMailInbound)).Methods("POST") r.NotFoundHandler = http.HandlerFunc(a.mw.NotFoundHandler) r.Use(a.mw.JsonHeader) @@ -153,6 +163,23 @@ func (a *Api) RegisterOIDCClient(req *http.Request) (interface{}, error) { return password, err } +func (a *Api) RegisterMailInbound(req *http.Request) (interface{}, error) { + if err := req.ParseForm(); err != nil { + return nil, err + } + socket := req.FormValue("socket") + if socket == "" { + return nil, fmt.Errorf("socket is required") + } + a.userConfig.SetMailInboundSocket(&socket) + return socket, a.relay.Apply(a.userConfig.IsRelayEnabled()) +} + +func (a *Api) UnregisterMailInbound(_ *http.Request) (interface{}, error) { + a.userConfig.SetMailInboundSocket(nil) + return "unregistered", a.relay.Apply(a.userConfig.IsRelayEnabled()) +} + func (a *Api) ConfigGetDkimKey(_ *http.Request) (interface{}, error) { return a.userConfig.GetDkimKey(), nil } diff --git a/config/frp/frpc.toml b/config/frp/frpc.toml index 0744045b..f36a1cf4 100644 --- a/config/frp/frpc.toml +++ b/config/frp/frpc.toml @@ -10,11 +10,13 @@ type = "https" customDomains = ["{{ .Domain }}", "*.{{ .Domain }}"] localIP = "127.0.0.1" localPort = {{ .LocalPort }} - +{{ if .MailSocket }} [[proxies]] name = "{{ .Domain }}-smtp" type = "tcpmux" multiplexer = "httpconnect" customDomains = ["{{ .Domain }}"] -localIP = "127.0.0.1" -localPort = {{ .MailLocalPort }} +[proxies.plugin] +type = "unix_domain_socket" +unixPath = "{{ .MailSocket }}" +{{ end }} \ No newline at end of file From 7fd35e14c063b21f1568d07d36abaf437a3b0f94 Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Sun, 9 Aug 2026 10:06:18 +0100 Subject: [PATCH 7/7] Skip the visual diff against a stable build whose pictures are gone The artifacts for 3055 have been pruned off the ci host, so there is nothing to compare against and the step fails having compared nothing. --- .drone.jsonnet | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.drone.jsonnet b/.drone.jsonnet index d86e5fee..c5e492ab 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -10,7 +10,7 @@ local bootstrap = '25.02'; local nginx = '1.24.0'; local python = '3.12-slim-bookworm'; local alpine = '3.21'; -local visual_diff_skip_build = '3040'; +local visual_diff_skip_build = '3055'; local build(arch, testUI) = [{ kind: 'pipeline',