diff --git a/.drone.jsonnet b/.drone.jsonnet index 1c2f133f..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', @@ -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', ], }, diff --git a/backend/access/external_address.go b/backend/access/external_address.go index 816ff85b..1d6dc5d0 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,10 @@ func (a *ExternalAddress) Update(request model.Access) error { a.userConfig.SetIpv6Enabled(request.Ipv6Enabled) a.userConfig.SetPublicPort(request.AccessPort) + if err := a.relay.Apply(request.RelayEnabled); err != nil { + return err + } + a.trigger.Trigger() return nil @@ -171,5 +168,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..4b6dc276 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,25 @@ 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) + 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) + assert.Equal(t, 1, relay.applied) +} diff --git a/backend/access/relay_client.go b/backend/access/relay_client.go index dd767dc5..868fa9e7 100644 --- a/backend/access/relay_client.go +++ b/backend/access/relay_client.go @@ -21,6 +21,8 @@ const ( relayAdminSocket = "frpc-admin.sock" relayAdminUrl = "http://unix/api/status" relayConnectAttempts = 30 + + SmtpProxySuffix = "-smtp" ) type RelayControl interface { @@ -38,11 +40,13 @@ type frpcConfig struct { AdminSocket string Domain string LocalPort int + MailSocket string } type RelayUserConfig interface { GetDeviceDomain() string GetDomainUpdateToken() *string + GetMailInboundSocket() *string } type RelayRedirectConfig interface { @@ -56,6 +60,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 +72,8 @@ func NewRelayClient(control RelayControl, systemConfig RelaySystemConfig, userCo redirect: redirect, client: client, logger: logger, + + connectAttempts: relayConnectAttempts, } } @@ -89,7 +97,12 @@ func (c *RelayClient) adminSocketPath() string { return filepath.Join(c.systemConfig.DataDir(), relayAdminSocket) } -func (c *RelayClient) Enable() error { +func (c *RelayClient) Apply(relayEnabled bool) error { + if !relayEnabled { + return c.Disable() + } + mailSocket := c.userConfig.GetMailInboundSocket() + domain := c.userConfig.GetDeviceDomain() token := c.userConfig.GetDomainUpdateToken() if token == nil { @@ -100,31 +113,49 @@ func (c *RelayClient) Enable() error { if err != nil { return err } - var content bytes.Buffer - err = tmpl.Execute(&content, frpcConfig{ + settings := frpcConfig{ Server: server, Token: *token, AdminSocket: c.adminSocketPath(), Domain: domain, LocalPort: config.WebAccessPort, - }) - if err != nil { + MailSocket: socketPath(mailSocket), + } + var content bytes.Buffer + 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, mailSocket != nil) + 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, 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 { @@ -135,38 +166,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..e06838ae 100644 --- a/backend/access/relay_client_test.go +++ b/backend/access/relay_client_test.go @@ -36,8 +36,13 @@ func (c *relaySystemConfigStub) ConfigDir() string { } type relayUserConfigStub struct { - domain string - token *string + domain string + token *string + mailSocket *string +} + +func (c *relayUserConfigStub) GetMailInboundSocket() *string { + return c.mailSocket } func (c *relayUserConfigStub) GetDeviceDomain() string { @@ -64,18 +69,13 @@ 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{"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"}, relayRunningProxies("name.syncloud.it", "name.syncloud.it-smtp"), zap.NewNop()) - err := client.Enable() + err := client.Apply(true) assert.Nil(t, err) content, err := os.ReadFile(filepath.Join(dir, "frpc.toml")) @@ -93,17 +93,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"}, relayRunningProxies("name.syncloud.it", "name.syncloud.it-smtp"), 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"}, relayRunningProxies("name.syncloud.it", "name.syncloud.it-smtp"), zap.NewNop()) + assert.NotNil(t, client.Apply(true)) } func TestRelayClient_DisableRemovesConfigAndRestartsToIdle(t *testing.T) { @@ -111,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{"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"}, relayRunningProxies("name.syncloud.it", "name.syncloud.it-smtp"), zap.NewNop()) err := client.Disable() assert.Nil(t, err) @@ -122,7 +122,85 @@ 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"}, relayRunningProxies("name.syncloud.it", "name.syncloud.it-smtp"), 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, socket *string, running ...string) (*RelayClient, *relayControlStub) { + t.Helper() + token := "tok123" + control := &relayControlStub{} + 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_RegisteredMailOpensTheSmtpProxy(t *testing.T) { + dir := t.TempDir() + 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)) + + s, err := readConfig(dir) + assert.Nil(t, err) + 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, `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, 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() + socket := "/var/snap/mail/common/mail.socket" + client, _ := mailClient(t, dir, &socket, "name.syncloud.it") + client.connectAttempts = 2 + + assert.NotNil(t, client.Apply(true)) +} + +func readConfig(dir string) (string, error) { + content, err := os.ReadFile(filepath.Join(dir, "frpc.toml")) + if err != nil { + return "", err + } + return string(content), nil +} diff --git a/backend/config/system_config.go b/backend/config/system_config.go index 0c97ea0b..6eb75335 100644 --- a/backend/config/system_config.go +++ b/backend/config/system_config.go @@ -6,6 +6,7 @@ import ( ) const WebAccessPort = 443 + 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 5e8550e2..f36a1cf4 100644 --- a/config/frp/frpc.toml +++ b/config/frp/frpc.toml @@ -10,3 +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 }}"] +[proxies.plugin] +type = "unix_domain_socket" +unixPath = "{{ .MailSocket }}" +{{ end }} \ No newline at end of file