From b7fcc1b24ddb4333a6c58751db292d3ca99fd268 Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Tue, 3 Mar 2026 21:10:53 +0000 Subject: [PATCH 1/3] custom proxy nginx with go templates --- .drone.jsonnet | 9 ++ backend/config/user_config.go | 57 +++++++++++ backend/hook/install.go | 10 ++ backend/ioc/common.go | 9 +- backend/ioc/public_api.go | 4 +- backend/nginx/proxy_adapter.go | 23 +++++ backend/nginx/service.go | 74 ++++++++++++-- backend/nginx/service_test.go | 95 +++++++++++++++-- backend/rest/backend.go | 7 +- backend/rest/custom_proxy.go | 73 +++++++++++++ bin/service.nginx-custom-proxy.sh | 22 ++++ config/nginx/custom-proxy.conf | 47 +++++++++ config/nginx/public.conf | 28 +++-- meta/snap.yaml | 9 ++ test/externalapp/.gitignore | 1 + test/externalapp/go.mod | 3 + test/externalapp/main.go | 23 +++++ test/test.py | 27 +++++ test/ui.py | 21 ++++ www/src/router/index.js | 1 + www/src/views/CustomProxy.vue | 163 ++++++++++++++++++++++++++++++ www/src/views/Settings.vue | 7 ++ 22 files changed, 691 insertions(+), 22 deletions(-) create mode 100644 backend/nginx/proxy_adapter.go create mode 100644 backend/rest/custom_proxy.go create mode 100755 bin/service.nginx-custom-proxy.sh create mode 100644 config/nginx/custom-proxy.conf create mode 100644 test/externalapp/.gitignore create mode 100644 test/externalapp/go.mod create mode 100644 test/externalapp/main.go create mode 100644 www/src/views/CustomProxy.vue diff --git a/.drone.jsonnet b/.drone.jsonnet index 23371dc32..2167c410f 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -106,6 +106,14 @@ local build(arch, testUI) = [{ "go test -c -ldflags '-linkmode external -extldflags -static' -o api.test", ], }, + { + name: 'build external app', + image: 'golang:' + go, + commands: [ + 'cd test/externalapp', + "CGO_ENABLED=0 go build -ldflags '-extldflags -static' -o externalapp", + ], + }, { name: 'package', image: 'debian:bookworm-slim', @@ -145,6 +153,7 @@ local build(arch, testUI) = [{ 'getent hosts $DOMAIN | sed "s/$DOMAIN/auth.$DOMAIN.redirect/g" | sudo tee -a /etc/hosts', 'getent hosts $DOMAIN | sed "s/$DOMAIN/$DOMAIN.redirect/g" | sudo tee -a /etc/hosts', 'getent hosts $DOMAIN | sed "s/$DOMAIN/unknown.$DOMAIN.redirect/g" | sudo tee -a /etc/hosts', + 'getent hosts $DOMAIN | sed "s/$DOMAIN/externalapp.$DOMAIN.redirect/g" | sudo tee -a /etc/hosts', 'cat /etc/hosts', '/opt/bin/entry_point.sh', ], diff --git a/backend/config/user_config.go b/backend/config/user_config.go index 35c05cf5d..caa1e8a19 100644 --- a/backend/config/user_config.go +++ b/backend/config/user_config.go @@ -80,6 +80,11 @@ func (c *UserConfig) ensureDb() error { return err } + err = c.addCustomProxyTable() + if err != nil { + return err + } + return nil } @@ -588,3 +593,55 @@ func (c *UserConfig) Url(app string) string { domain := c.GetDeviceDomain() return ConstructUrl(port, fmt.Sprintf("%s.%s", app, domain)) } + +func (c *UserConfig) addCustomProxyTable() error { + db := c.open() + defer db.Close() + + query := `create table if not exists custom_proxy + (name varchar primary key, host varchar, port integer)` + _, err := db.Exec(query) + if err != nil { + return fmt.Errorf("unable to add custom_proxy table: %s", err) + } + return nil +} + +type CustomProxyEntry struct { + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port"` +} + +func (c *UserConfig) AddCustomProxy(name string, host string, port int) error { + db := c.open() + defer db.Close() + _, err := db.Exec("INSERT OR REPLACE INTO custom_proxy VALUES (?, ?, ?)", name, host, port) + return err +} + +func (c *UserConfig) RemoveCustomProxy(name string) error { + db := c.open() + defer db.Close() + _, err := db.Exec("DELETE FROM custom_proxy WHERE name = ?", name) + return err +} + +func (c *UserConfig) CustomProxies() ([]CustomProxyEntry, error) { + db := c.open() + defer db.Close() + rows, err := db.Query("select name, host, port from custom_proxy") + if err != nil { + return nil, err + } + entries := make([]CustomProxyEntry, 0) + defer rows.Close() + for rows.Next() { + var entry CustomProxyEntry + if err := rows.Scan(&entry.Name, &entry.Host, &entry.Port); err != nil { + return entries, err + } + entries = append(entries, entry) + } + return entries, rows.Err() +} diff --git a/backend/hook/install.go b/backend/hook/install.go index b57aa8574..b048fca1f 100644 --- a/backend/hook/install.go +++ b/backend/hook/install.go @@ -46,6 +46,7 @@ type Ldap interface { type Nginx interface { InitConfig() error + InitCustomProxyConfig() error } type SystemdControl interface { @@ -114,6 +115,10 @@ func (i *Install) Install() error { if err != nil { return err } + err = i.nginx.InitCustomProxyConfig() + if err != nil { + return err + } err = i.web.InitConfig() if err != nil { return err @@ -138,6 +143,11 @@ func (i *Install) PostRefresh() error { return err } + err = i.nginx.InitCustomProxyConfig() + if err != nil { + return err + } + err = i.web.InitConfig() if err != nil { return err diff --git a/backend/ioc/common.go b/backend/ioc/common.go index 1239e1af4..ef303265e 100644 --- a/backend/ioc/common.go +++ b/backend/ioc/common.go @@ -111,7 +111,7 @@ func Init(userConfig string, systemConfig string, backupDir string, varDir strin return nil, err } err = c.Singleton(func(systemConfig *config.SystemConfig, userConfig *config.UserConfig, control *systemd.Control) *nginx.Nginx { - return nginx.New(control, systemConfig, userConfig) + return nginx.New(control, systemConfig, userConfig, nginx.NewProxyConfigAdapter(userConfig)) }) if err != nil { return nil, err @@ -450,6 +450,13 @@ func Init(userConfig string, systemConfig string, backupDir string, varDir strin return rest.NewProxy(userConfig) }) + if err != nil { + return nil, err + } + err = c.Singleton(func(userConfig *config.UserConfig, nginxService *nginx.Nginx) *rest.CustomProxy { + return rest.NewCustomProxy(userConfig, nginxService) + }) + if err != nil { return nil, err } diff --git a/backend/ioc/public_api.go b/backend/ioc/public_api.go index a0a5215d0..c9feaedf9 100644 --- a/backend/ioc/public_api.go +++ b/backend/ioc/public_api.go @@ -34,12 +34,12 @@ func InitPublicApi(userConfig string, systemConfig string, backupDir string, var id *identification.Parser, activate *rest.Activate, userConfig *config.UserConfig, cert *rest.Certificate, externalAddress *access.ExternalAddress, snapd *snap.Server, disks *storage.Disks, journalCtl *systemd.Journal, executor *cli.ShellExecutor, iface *network.TcpInterfaces, sender *support.Sender, - proxy *rest.Proxy, middleware *rest.Middleware, ldapService *auth.Service, cookies *session.Cookies, + proxy *rest.Proxy, customProxy *rest.CustomProxy, middleware *rest.Middleware, ldapService *auth.Service, cookies *session.Cookies, changesClient *snap.ChangesClient, ) *rest.Backend { return rest.NewBackend(master, backupService, eventTrigger, worker, redirectService, installerService, storageService, id, activate, userConfig, cert, externalAddress, - snapd, disks, journalCtl, executor, iface, sender, proxy, + snapd, disks, journalCtl, executor, iface, sender, proxy, customProxy, ldapService, middleware, cookies, net, address, changesClient, logger) }) if err != nil { diff --git a/backend/nginx/proxy_adapter.go b/backend/nginx/proxy_adapter.go new file mode 100644 index 000000000..b3a0254f8 --- /dev/null +++ b/backend/nginx/proxy_adapter.go @@ -0,0 +1,23 @@ +package nginx + +import "github.com/syncloud/platform/config" + +type ProxyConfigAdapter struct { + userConfig *config.UserConfig +} + +func NewProxyConfigAdapter(userConfig *config.UserConfig) *ProxyConfigAdapter { + return &ProxyConfigAdapter{userConfig: userConfig} +} + +func (a *ProxyConfigAdapter) Proxies() ([]ProxyEntry, error) { + entries, err := a.userConfig.CustomProxies() + if err != nil { + return nil, err + } + result := make([]ProxyEntry, len(entries)) + for i, e := range entries { + result[i] = ProxyEntry{Name: e.Name, Host: e.Host, Port: e.Port} + } + return result, nil +} diff --git a/backend/nginx/service.go b/backend/nginx/service.go index 01c88628a..a39699d53 100644 --- a/backend/nginx/service.go +++ b/backend/nginx/service.go @@ -1,9 +1,11 @@ package nginx import ( + "bytes" "os" "path" "strings" + "text/template" ) type Systemd interface { @@ -19,17 +21,29 @@ type UserConfig interface { GetDeviceDomain() string } +type ProxyEntry struct { + Name string + Host string + Port int +} + +type ProxyConfig interface { + Proxies() ([]ProxyEntry, error) +} + type Nginx struct { systemd Systemd systemConfig SystemConfig userConfig UserConfig + proxyConfig ProxyConfig } -func New(systemd Systemd, systemConfig SystemConfig, userConfig UserConfig) *Nginx { +func New(systemd Systemd, systemConfig SystemConfig, userConfig UserConfig, proxyConfig ProxyConfig) *Nginx { return &Nginx{ systemd: systemd, userConfig: userConfig, systemConfig: systemConfig, + proxyConfig: proxyConfig, } } @@ -44,11 +58,59 @@ func (n *Nginx) InitConfig() error { if err != nil { return err } - template := string(templateFile) - template = strings.ReplaceAll(template, "{{ domain_regex }}", strings.ReplaceAll(domain, ".", "\\.")) - template = strings.ReplaceAll(template, "{{ domain }}", domain) + tmpl := string(templateFile) + tmpl = strings.ReplaceAll(tmpl, "{{ domain_regex }}", strings.ReplaceAll(domain, ".", "\\.")) + tmpl = strings.ReplaceAll(tmpl, "{{ domain }}", domain) nginxConfigDir := n.systemConfig.DataDir() nginxConfigFile := path.Join(nginxConfigDir, "nginx.conf") - err = os.WriteFile(nginxConfigFile, []byte(template), 0644) - return err + return os.WriteFile(nginxConfigFile, []byte(tmpl), 0644) +} + +type customProxyTemplateData struct { + Entries []customProxyServerEntry +} + +type customProxyServerEntry struct { + ServerName string + Host string + Port int +} + +func (n *Nginx) InitCustomProxyConfig() error { + domain := n.userConfig.GetDeviceDomain() + configDir := n.systemConfig.ConfigDir() + templateFile := path.Join(configDir, "nginx", "custom-proxy.conf") + + tmpl, err := template.ParseFiles(templateFile) + if err != nil { + return err + } + + entries, err := n.proxyConfig.Proxies() + if err != nil { + return err + } + + serverEntries := make([]customProxyServerEntry, len(entries)) + for i, e := range entries { + serverEntries[i] = customProxyServerEntry{ + ServerName: e.Name + "." + domain, + Host: e.Host, + Port: e.Port, + } + } + + var buf bytes.Buffer + err = tmpl.Execute(&buf, customProxyTemplateData{Entries: serverEntries}) + if err != nil { + return err + } + + nginxConfigDir := n.systemConfig.DataDir() + nginxConfigFile := path.Join(nginxConfigDir, "custom-proxy.conf") + err = os.WriteFile(nginxConfigFile, buf.Bytes(), 0644) + if err != nil { + return err + } + return n.systemd.ReloadService("platform.nginx-custom-proxy") } diff --git a/backend/nginx/service_test.go b/backend/nginx/service_test.go index 516a33f21..dab7f92b9 100644 --- a/backend/nginx/service_test.go +++ b/backend/nginx/service_test.go @@ -3,15 +3,18 @@ package nginx import ( "os" "path" + "strings" "testing" "github.com/stretchr/testify/assert" ) type SystemdMock struct { + reloadedService string } -func (s *SystemdMock) ReloadService(_ string) error { +func (s *SystemdMock) ReloadService(service string) error { + s.reloadedService = service return nil } @@ -36,22 +39,102 @@ func (u *UserConfigMock) GetDeviceDomain() string { return u.deviceDomain } -func TestSubstitution(t *testing.T) { +type ProxyConfigMock struct { + entries []ProxyEntry +} - outputDir := t.TempDir() +func (p *ProxyConfigMock) Proxies() ([]ProxyEntry, error) { + return p.entries, nil +} +func TestSubstitution(t *testing.T) { + outputDir := t.TempDir() configDir := path.Join("..", "..", "config") systemd := &SystemdMock{} systemConfig := &SystemConfigMock{configDir: configDir, dataDir: outputDir} userConfig := &UserConfigMock{"example.com"} - nginx := New(systemd, systemConfig, userConfig) + proxyConfig := &ProxyConfigMock{} + nginx := New(systemd, systemConfig, userConfig, proxyConfig) err := nginx.InitConfig() assert.Nil(t, err) - resultFile := path.Join(outputDir, "nginx.conf") - contents, err := os.ReadFile(resultFile) + contents, err := os.ReadFile(path.Join(outputDir, "nginx.conf")) assert.Nil(t, err) assert.Contains(t, string(contents), "server_name example.com;") assert.Contains(t, string(contents), "server_name ~^(.*\\.)?(?P.*)\\.example\\.com$;") + assert.Contains(t, string(contents), "@custom_proxy") +} + +func TestCustomProxy_ZeroEntries(t *testing.T) { + outputDir := t.TempDir() + configDir := path.Join("..", "..", "config") + systemd := &SystemdMock{} + systemConfig := &SystemConfigMock{configDir: configDir, dataDir: outputDir} + userConfig := &UserConfigMock{"example.com"} + proxyConfig := &ProxyConfigMock{} + nginx := New(systemd, systemConfig, userConfig, proxyConfig) + + err := nginx.InitCustomProxyConfig() + assert.Nil(t, err) + + contents, err := os.ReadFile(path.Join(outputDir, "custom-proxy.conf")) + assert.Nil(t, err) + text := string(contents) + + assert.Contains(t, text, "return 502") + assert.NotContains(t, text, "proxy_pass http://") + assert.Equal(t, 1, strings.Count(text, "listen unix:"), "should have only the default server block") + assert.Equal(t, "platform.nginx-custom-proxy", systemd.reloadedService) +} + +func TestCustomProxy_OneEntry(t *testing.T) { + outputDir := t.TempDir() + configDir := path.Join("..", "..", "config") + systemd := &SystemdMock{} + systemConfig := &SystemConfigMock{configDir: configDir, dataDir: outputDir} + userConfig := &UserConfigMock{"example.com"} + proxyConfig := &ProxyConfigMock{entries: []ProxyEntry{ + {Name: "myapp", Host: "192.168.1.10", Port: 8080}, + }} + nginx := New(systemd, systemConfig, userConfig, proxyConfig) + + err := nginx.InitCustomProxyConfig() + assert.Nil(t, err) + + contents, err := os.ReadFile(path.Join(outputDir, "custom-proxy.conf")) + assert.Nil(t, err) + text := string(contents) + + assert.Contains(t, text, "server_name myapp.example.com;") + assert.Contains(t, text, "proxy_pass http://192.168.1.10:8080;") + assert.Equal(t, 2, strings.Count(text, "listen unix:"), "should have default + 1 custom server block") + assert.Equal(t, "platform.nginx-custom-proxy", systemd.reloadedService) +} + +func TestCustomProxy_TwoEntries(t *testing.T) { + outputDir := t.TempDir() + configDir := path.Join("..", "..", "config") + systemd := &SystemdMock{} + systemConfig := &SystemConfigMock{configDir: configDir, dataDir: outputDir} + userConfig := &UserConfigMock{"mydevice.syncloud.it"} + proxyConfig := &ProxyConfigMock{entries: []ProxyEntry{ + {Name: "nas", Host: "192.168.1.50", Port: 5000}, + {Name: "camera", Host: "10.0.0.100", Port: 8443}, + }} + nginx := New(systemd, systemConfig, userConfig, proxyConfig) + + err := nginx.InitCustomProxyConfig() + assert.Nil(t, err) + + contents, err := os.ReadFile(path.Join(outputDir, "custom-proxy.conf")) + assert.Nil(t, err) + text := string(contents) + + assert.Contains(t, text, "server_name nas.mydevice.syncloud.it;") + assert.Contains(t, text, "proxy_pass http://192.168.1.50:5000;") + assert.Contains(t, text, "server_name camera.mydevice.syncloud.it;") + assert.Contains(t, text, "proxy_pass http://10.0.0.100:8443;") + assert.Equal(t, 3, strings.Count(text, "listen unix:"), "should have default + 2 custom server blocks") + assert.Equal(t, "platform.nginx-custom-proxy", systemd.reloadedService) } diff --git a/backend/rest/backend.go b/backend/rest/backend.go index b7fb5af82..bc013529d 100644 --- a/backend/rest/backend.go +++ b/backend/rest/backend.go @@ -48,6 +48,7 @@ type Backend struct { iface *network.TcpInterfaces support *support.Sender proxy *Proxy + customProxy *CustomProxy auth *auth.Service mw *Middleware cookies *session.Cookies @@ -62,7 +63,7 @@ func NewBackend( identification *identification.Parser, activate *Activate, userConfig *config.UserConfig, certificate *Certificate, externalAddress *access.ExternalAddress, snapd *snap.Server, disks *storage.Disks, journalCtl *systemd.Journal, executor *cli.ShellExecutor, - iface *network.TcpInterfaces, support *support.Sender, proxy *Proxy, + iface *network.TcpInterfaces, support *support.Sender, proxy *Proxy, customProxy *CustomProxy, auth *auth.Service, middleware *Middleware, cookies *session.Cookies, network string, address string, changesClient *snap.ChangesClient, logger *zap.Logger) *Backend { @@ -87,6 +88,7 @@ func NewBackend( iface: iface, support: support, proxy: proxy, + customProxy: customProxy, auth: auth, mw: middleware, cookies: cookies, @@ -161,6 +163,9 @@ func (b *Backend) Start() error { r.HandleFunc("/rest/shutdown", b.mw.FailIfNotActivated(b.mw.SecuredHandle(b.Shutdown))).Methods("POST") r.HandleFunc("/rest/network/interfaces", b.mw.FailIfNotActivated(b.mw.SecuredHandle(b.NetworkInterfaces))).Methods("GET") r.PathPrefix("/rest/proxy/image").HandlerFunc(b.mw.FailIfNotActivated(b.mw.Secured(b.proxy.ProxyImageFunc()))) + r.HandleFunc("/rest/proxy_custom/list", b.mw.FailIfNotActivated(b.mw.SecuredHandle(b.customProxy.List))).Methods("GET") + r.HandleFunc("/rest/proxy_custom/add", b.mw.FailIfNotActivated(b.mw.SecuredHandle(b.customProxy.Add))).Methods("POST") + r.HandleFunc("/rest/proxy_custom/remove", b.mw.FailIfNotActivated(b.mw.SecuredHandle(b.customProxy.Remove))).Methods("POST") r.NotFoundHandler = http.HandlerFunc(b.mw.NotFoundHandler) diff --git a/backend/rest/custom_proxy.go b/backend/rest/custom_proxy.go new file mode 100644 index 000000000..3c5204c18 --- /dev/null +++ b/backend/rest/custom_proxy.go @@ -0,0 +1,73 @@ +package rest + +import ( + "encoding/json" + "errors" + "fmt" + "github.com/syncloud/platform/config" + "net/http" +) + +type CustomProxyNginx interface { + InitCustomProxyConfig() error +} + +type CustomProxy struct { + config *config.UserConfig + nginx CustomProxyNginx +} + +func NewCustomProxy(config *config.UserConfig, nginx CustomProxyNginx) *CustomProxy { + return &CustomProxy{ + config: config, + nginx: nginx, + } +} + +func (cp *CustomProxy) List(_ *http.Request) (interface{}, error) { + return cp.config.CustomProxies() +} + +type customProxyAddRequest struct { + Name string `json:"name"` + Host string `json:"host"` + Port int `json:"port"` +} + +func (cp *CustomProxy) Add(req *http.Request) (interface{}, error) { + var request customProxyAddRequest + err := json.NewDecoder(req.Body).Decode(&request) + if err != nil { + fmt.Printf("parse error: %v\n", err.Error()) + return nil, errors.New("bad request") + } + if request.Name == "" || request.Host == "" || request.Port == 0 { + return nil, errors.New("name, host and port are required") + } + err = cp.config.AddCustomProxy(request.Name, request.Host, request.Port) + if err != nil { + return nil, err + } + return "OK", cp.nginx.InitCustomProxyConfig() +} + +type customProxyRemoveRequest struct { + Name string `json:"name"` +} + +func (cp *CustomProxy) Remove(req *http.Request) (interface{}, error) { + var request customProxyRemoveRequest + err := json.NewDecoder(req.Body).Decode(&request) + if err != nil { + fmt.Printf("parse error: %v\n", err.Error()) + return nil, errors.New("bad request") + } + if request.Name == "" { + return nil, errors.New("name is required") + } + err = cp.config.RemoveCustomProxy(request.Name) + if err != nil { + return nil, err + } + return "OK", cp.nginx.InitCustomProxyConfig() +} diff --git a/bin/service.nginx-custom-proxy.sh b/bin/service.nginx-custom-proxy.sh new file mode 100755 index 000000000..2fb11cf4a --- /dev/null +++ b/bin/service.nginx-custom-proxy.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd ) + +if [[ -z "$1" ]]; then + echo "usage $0 [start|stop]" + exit 1 +fi + +case $1 in +start) + ${DIR}/nginx/bin/nginx.sh -t -c /var/snap/platform/current/custom-proxy.conf -e stderr + exec $DIR/nginx/bin/nginx.sh -c /var/snap/platform/current/custom-proxy.conf -e stderr + ;; +reload) + $DIR/nginx/bin/nginx.sh -c /var/snap/platform/current/custom-proxy.conf -s reload -e stderr + ;; +*) + echo "not valid command" + exit 1 + ;; +esac diff --git a/config/nginx/custom-proxy.conf b/config/nginx/custom-proxy.conf new file mode 100644 index 000000000..81a11b8ab --- /dev/null +++ b/config/nginx/custom-proxy.conf @@ -0,0 +1,47 @@ +user root; + +worker_processes 1; + +pid /var/snap/platform/common/log/nginx_custom_proxy.pid; +error_log syslog:server=unix:/dev/log warn; + +events { + worker_connections 256; +} + +http { + access_log syslog:server=unix:/dev/log; + + client_body_temp_path /var/snap/platform/current/nginx/custom_proxy_client_body_temp; + proxy_temp_path /var/snap/platform/current/nginx/custom_proxy_proxy_temp; + fastcgi_temp_path /var/snap/platform/current/nginx/custom_proxy_fastcgi_temp; + uwsgi_temp_path /var/snap/platform/current/nginx/custom_proxy_uwsgi_temp; + scgi_temp_path /var/snap/platform/current/nginx/custom_proxy_scgi_temp; + + client_max_body_size 10G; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + proxy_buffering off; + + server { + listen unix:/var/snap/platform/current/custom-proxy.socket; + return 502; + } +{{range .Entries}} + server { + listen unix:/var/snap/platform/current/custom-proxy.socket; + server_name {{.ServerName}}; + location / { + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $http_host; + proxy_pass http://{{.Host}}:{{.Port}}; + } + } +{{end}} +} diff --git a/config/nginx/public.conf b/config/nginx/public.conf index e3e8787e5..709e89b16 100644 --- a/config/nginx/public.conf +++ b/config/nginx/public.conf @@ -125,12 +125,12 @@ http { # apps proxy server { - + listen 443 ssl http2; listen [::]:443 ssl http2; - + server_name ~^(.*\.)?(?P.*)\.{{ domain_regex }}$; - + ssl_certificate /var/snap/platform/current/syncloud.crt; ssl_certificate_key /var/snap/platform/current/syncloud.key; ssl_protocols TLSv1.2 TLSv1.3; @@ -140,9 +140,7 @@ http { add_header Strict-Transport-Security "max-age=31536000; includeSubdomains"; - error_page 502 /502.html; - error_page 503 /502.html; - error_page 504 /502.html; + error_page 502 = @custom_proxy; location = /502.html { root /snap/platform/current/config/errors; @@ -160,6 +158,24 @@ http { proxy_set_header Host $http_host; proxy_pass http://unix:/var/snap/$app/common/web.socket: ; proxy_redirect http://unix:/var/snap/$app/common/web.socket: $scheme://$http_host ; + proxy_intercept_errors on; + } + + location @custom_proxy { + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $http_host; + proxy_pass http://unix:/var/snap/platform/current/custom-proxy.socket: ; + proxy_redirect http://unix:/var/snap/platform/current/custom-proxy.socket: $scheme://$http_host ; + + error_page 502 /502.html; + error_page 503 /502.html; + error_page 504 /502.html; } } diff --git a/meta/snap.yaml b/meta/snap.yaml index 477ef97a0..8bf65a43a 100644 --- a/meta/snap.yaml +++ b/meta/snap.yaml @@ -8,6 +8,15 @@ apps: restart-condition: always reload-command: bin/service.nginx-public.sh reload start-timeout: 2000s + nginx-custom-proxy: + command: bin/service.nginx-custom-proxy.sh start + daemon: forking + plugs: + - network + - network-bind + restart-condition: always + reload-command: bin/service.nginx-custom-proxy.sh reload + start-timeout: 2000s openldap: command: bin/service.openldap.sh daemon: forking diff --git a/test/externalapp/.gitignore b/test/externalapp/.gitignore new file mode 100644 index 000000000..c44dbe71e --- /dev/null +++ b/test/externalapp/.gitignore @@ -0,0 +1 @@ +externalapp diff --git a/test/externalapp/go.mod b/test/externalapp/go.mod new file mode 100644 index 000000000..e473d5474 --- /dev/null +++ b/test/externalapp/go.mod @@ -0,0 +1,3 @@ +module externalapp + +go 1.24 diff --git a/test/externalapp/main.go b/test/externalapp/main.go new file mode 100644 index 000000000..8c24b9162 --- /dev/null +++ b/test/externalapp/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "fmt" + "net/http" + "os" +) + +func main() { + port := "8585" + if len(os.Args) > 1 { + port = os.Args[1] + } + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, "external") + }) + fmt.Printf("listening on :%s\n", port) + err := http.ListenAndServe(":"+port, nil) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } +} diff --git a/test/test.py b/test/test.py index 14e010551..ffde25a15 100644 --- a/test/test.py +++ b/test/test.py @@ -265,6 +265,33 @@ def test_api(device): device.run_ssh('/api.test') +def test_custom_proxy(device, device_host, domain): + device.run_ssh('nohup /test/externalapp/externalapp > /tmp/syncloud/externalapp.log 2>&1 &', throw=False) + time.sleep(2) + add_host_alias("externalapp", device_host, domain) + session = device.login() + response = session.post('https://{0}/rest/proxy_custom/add'.format(device_host), + json={'name': 'externalapp', 'host': 'localhost', 'port': 8585}, + verify=False) + assert response.status_code == 200, response.text + assert json.loads(response.text)["success"], response.text + + response = session.get('https://{0}/rest/proxy_custom/list'.format(device_host), verify=False) + assert response.status_code == 200, response.text + proxies = json.loads(response.text)["data"] + assert len(proxies) == 1 + assert proxies[0]["name"] == "externalapp" + + response = requests.get('https://externalapp.{0}'.format(domain), verify=False) + assert response.status_code == 200, response.text + assert response.text == "external", response.text + + response = session.post('https://{0}/rest/proxy_custom/remove'.format(device_host), + json={'name': 'externalapp'}, + verify=False) + assert response.status_code == 200, response.text + + def test_testapp_access_change(device_host, domain): output = run_ssh(device_host, 'cat /var/snap/testapp/common/on_access_change', password=LOGS_SSH_PASSWORD) assert not output.strip() == "https://testapp.{0}".format(domain) diff --git a/test/ui.py b/test/ui.py index 3fd99c998..b8b4c208c 100644 --- a/test/ui.py +++ b/test/ui.py @@ -216,6 +216,27 @@ def test_not_installed_app(selenium): +def test_settings_custom_proxy(selenium, device, device_host, domain, full_domain): + device.run_ssh('nohup /test/externalapp/externalapp > /tmp/syncloud/ui/externalapp.log 2>&1 &', throw=False) + add_host_alias("externalapp", device_host, domain) + settings(selenium, 'customproxy') + selenium.find_by_xpath("//h1[text()='Custom Proxy']") + selenium.screenshot('settings_custom_proxy') + selenium.find_by_id('proxy_name').send_keys('externalapp') + selenium.find_by_id('proxy_host').send_keys('localhost') + selenium.find_by_id('proxy_port').send_keys('8585') + selenium.screenshot('settings_custom_proxy_filled') + selenium.find_by_id('btn_add').click() + wait_for_loading(selenium.driver) + selenium.screenshot('settings_custom_proxy_added') + + response = requests.get('https://externalapp.{0}'.format(full_domain), verify=False) + assert response.status_code == 200, response.text + assert response.text == "external", response.text + + selenium.screenshot('settings_custom_proxy_verified') + + def test_auth_web(selenium, full_domain, device_user, device_password): selenium.driver.get("https://auth.{0}".format(full_domain)) selenium.find_by(By.ID, "username-textfield").send_keys(device_user) diff --git a/www/src/router/index.js b/www/src/router/index.js index 736981e21..b06894c89 100644 --- a/www/src/router/index.js +++ b/www/src/router/index.js @@ -18,6 +18,7 @@ const routes = [ { path: '/certificate', name: 'Certificate', component: () => import('../views/Certificate.vue') }, { path: '/certificate/log', name: 'Certificate Log', component: () => import('../views/CertificateLog.vue') }, { path: '/logs', name: 'Logs', component: () => import('../views/Logs.vue') }, + { path: '/customproxy', name: 'CustomProxy', component: () => import('../views/CustomProxy.vue') }, { path: '/:catchAll(.*)', redirect: '/' } ] diff --git a/www/src/views/CustomProxy.vue b/www/src/views/CustomProxy.vue new file mode 100644 index 000000000..d5909813d --- /dev/null +++ b/www/src/views/CustomProxy.vue @@ -0,0 +1,163 @@ + + + + diff --git a/www/src/views/Settings.vue b/www/src/views/Settings.vue index 75c7152df..884a3ff19 100644 --- a/www/src/views/Settings.vue +++ b/www/src/views/Settings.vue @@ -75,6 +75,13 @@ +
+ + swap_horiz +
Custom Proxy
+
+
+ From 5bf8beb1e6a44eb194f1d65c3b57023fa5930632 Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Tue, 3 Mar 2026 22:12:28 +0000 Subject: [PATCH 2/3] custom proxy loop protection with X-Syncloud-Custom-Proxy header --- backend/nginx/service_test.go | 1 + config/nginx/custom-proxy.conf | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/backend/nginx/service_test.go b/backend/nginx/service_test.go index dab7f92b9..f438701b2 100644 --- a/backend/nginx/service_test.go +++ b/backend/nginx/service_test.go @@ -108,6 +108,7 @@ func TestCustomProxy_OneEntry(t *testing.T) { assert.Contains(t, text, "server_name myapp.example.com;") assert.Contains(t, text, "proxy_pass http://192.168.1.10:8080;") + assert.Contains(t, text, "X-Syncloud-Custom-Proxy") assert.Equal(t, 2, strings.Count(text, "listen unix:"), "should have default + 1 custom server block") assert.Equal(t, "platform.nginx-custom-proxy", systemd.reloadedService) } diff --git a/config/nginx/custom-proxy.conf b/config/nginx/custom-proxy.conf index 81a11b8ab..96faf5561 100644 --- a/config/nginx/custom-proxy.conf +++ b/config/nginx/custom-proxy.conf @@ -32,6 +32,10 @@ http { listen unix:/var/snap/platform/current/custom-proxy.socket; server_name {{.ServerName}}; location / { + if ($http_x_syncloud_custom_proxy) { + return 502; + } + proxy_set_header X-Syncloud-Custom-Proxy "1"; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Real-IP $remote_addr; From 27de37ef23ddb8c93b6cbb4130c6071b017f9525 Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Tue, 3 Mar 2026 22:16:57 +0000 Subject: [PATCH 3/3] do not reload custom proxy nginx during install hook --- backend/nginx/service.go | 18 +++++++++++++----- backend/nginx/service_test.go | 26 ++++++++++++++++++++++++-- backend/rest/custom_proxy.go | 6 +++--- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/backend/nginx/service.go b/backend/nginx/service.go index a39699d53..efa928072 100644 --- a/backend/nginx/service.go +++ b/backend/nginx/service.go @@ -77,6 +77,18 @@ type customProxyServerEntry struct { } func (n *Nginx) InitCustomProxyConfig() error { + return n.writeCustomProxyConfig() +} + +func (n *Nginx) ReloadCustomProxy() error { + err := n.writeCustomProxyConfig() + if err != nil { + return err + } + return n.systemd.ReloadService("platform.nginx-custom-proxy") +} + +func (n *Nginx) writeCustomProxyConfig() error { domain := n.userConfig.GetDeviceDomain() configDir := n.systemConfig.ConfigDir() templateFile := path.Join(configDir, "nginx", "custom-proxy.conf") @@ -108,9 +120,5 @@ func (n *Nginx) InitCustomProxyConfig() error { nginxConfigDir := n.systemConfig.DataDir() nginxConfigFile := path.Join(nginxConfigDir, "custom-proxy.conf") - err = os.WriteFile(nginxConfigFile, buf.Bytes(), 0644) - if err != nil { - return err - } - return n.systemd.ReloadService("platform.nginx-custom-proxy") + return os.WriteFile(nginxConfigFile, buf.Bytes(), 0644) } diff --git a/backend/nginx/service_test.go b/backend/nginx/service_test.go index f438701b2..ad97a14b2 100644 --- a/backend/nginx/service_test.go +++ b/backend/nginx/service_test.go @@ -85,7 +85,7 @@ func TestCustomProxy_ZeroEntries(t *testing.T) { assert.Contains(t, text, "return 502") assert.NotContains(t, text, "proxy_pass http://") assert.Equal(t, 1, strings.Count(text, "listen unix:"), "should have only the default server block") - assert.Equal(t, "platform.nginx-custom-proxy", systemd.reloadedService) + assert.Equal(t, "", systemd.reloadedService, "InitCustomProxyConfig should not reload") } func TestCustomProxy_OneEntry(t *testing.T) { @@ -110,7 +110,7 @@ func TestCustomProxy_OneEntry(t *testing.T) { assert.Contains(t, text, "proxy_pass http://192.168.1.10:8080;") assert.Contains(t, text, "X-Syncloud-Custom-Proxy") assert.Equal(t, 2, strings.Count(text, "listen unix:"), "should have default + 1 custom server block") - assert.Equal(t, "platform.nginx-custom-proxy", systemd.reloadedService) + assert.Equal(t, "", systemd.reloadedService, "InitCustomProxyConfig should not reload") } func TestCustomProxy_TwoEntries(t *testing.T) { @@ -137,5 +137,27 @@ func TestCustomProxy_TwoEntries(t *testing.T) { assert.Contains(t, text, "server_name camera.mydevice.syncloud.it;") assert.Contains(t, text, "proxy_pass http://10.0.0.100:8443;") assert.Equal(t, 3, strings.Count(text, "listen unix:"), "should have default + 2 custom server blocks") + assert.Equal(t, "", systemd.reloadedService, "InitCustomProxyConfig should not reload") +} + +func TestCustomProxy_ReloadCustomProxy(t *testing.T) { + outputDir := t.TempDir() + configDir := path.Join("..", "..", "config") + systemd := &SystemdMock{} + systemConfig := &SystemConfigMock{configDir: configDir, dataDir: outputDir} + userConfig := &UserConfigMock{"example.com"} + proxyConfig := &ProxyConfigMock{entries: []ProxyEntry{ + {Name: "myapp", Host: "192.168.1.10", Port: 8080}, + }} + nginx := New(systemd, systemConfig, userConfig, proxyConfig) + + err := nginx.ReloadCustomProxy() + assert.Nil(t, err) + + contents, err := os.ReadFile(path.Join(outputDir, "custom-proxy.conf")) + assert.Nil(t, err) + text := string(contents) + + assert.Contains(t, text, "server_name myapp.example.com;") assert.Equal(t, "platform.nginx-custom-proxy", systemd.reloadedService) } diff --git a/backend/rest/custom_proxy.go b/backend/rest/custom_proxy.go index 3c5204c18..2d9bf75c3 100644 --- a/backend/rest/custom_proxy.go +++ b/backend/rest/custom_proxy.go @@ -9,7 +9,7 @@ import ( ) type CustomProxyNginx interface { - InitCustomProxyConfig() error + ReloadCustomProxy() error } type CustomProxy struct { @@ -48,7 +48,7 @@ func (cp *CustomProxy) Add(req *http.Request) (interface{}, error) { if err != nil { return nil, err } - return "OK", cp.nginx.InitCustomProxyConfig() + return "OK", cp.nginx.ReloadCustomProxy() } type customProxyRemoveRequest struct { @@ -69,5 +69,5 @@ func (cp *CustomProxy) Remove(req *http.Request) (interface{}, error) { if err != nil { return nil, err } - return "OK", cp.nginx.InitCustomProxyConfig() + return "OK", cp.nginx.ReloadCustomProxy() }