From bbbef003293d40e79446f898de227d555e4b5b90 Mon Sep 17 00:00:00 2001 From: Daniel Redetzke Date: Sun, 4 Oct 2026 20:16:57 +0300 Subject: [PATCH] Web interface setting --- api.go | 9 +- app.css | 2 +- app.js | 27 +++ config.go | 14 ++ decoy.go | 539 +++++++++++++++++++++++++++++++++++++++++++++++++++ main_test.go | 75 +++++++ web.go | 50 ++++- 7 files changed, 709 insertions(+), 7 deletions(-) create mode 100644 decoy.go diff --git a/api.go b/api.go index 624536e..b69e8cd 100644 --- a/api.go +++ b/api.go @@ -159,8 +159,9 @@ func (a *App) routes() http.Handler { mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"}) }) - mux.HandleFunc("GET /setup/{token}", setupPage) - mux.Handle("/", webHandler()) + mux.HandleFunc("GET /setup/{token}", a.setupPage) + mux.HandleFunc("GET /setup/{token}/{file}", a.setupAsset) + mux.Handle("/", a.webHandler()) csrf := http.NewCrossOriginProtection() return securityHeaders(csrf.Handler(mux)) @@ -963,6 +964,7 @@ func (a *App) getSettings(w http.ResponseWriter, r *http.Request) { "web": cfg.Web, "log": cfg.Log, "stats": cfg.Stats, + "decoy": cfg.Decoy, "geo": a.geoStatus(), "adminUsername": a.username(cfg, who(r).UserID), // kept for older iOS app versions "fingerprint": a.tls.Fingerprint(), @@ -991,6 +993,9 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) { if err := field(m, "stats", &c.Stats); err != nil { return err } + if err := field(m, "decoy", &c.Decoy); err != nil { + return err + } return field(m, "log", &c.Log) }) if err != nil { diff --git a/app.css b/app.css index c0a5fc2..775a6d6 100644 --- a/app.css +++ b/app.css @@ -28,7 +28,7 @@ --brand: "Shippori Mincho B1", "Hiragino Mincho ProN", "Yu Mincho", serif; } -@font-face { font-family: "Shippori Mincho B1"; font-weight: 800; font-display: swap; src: url("/ShipporiMinchoB1-ExtraBold.woff2") format("woff2"); } +@font-face { font-family: "Shippori Mincho B1"; font-weight: 800; font-display: swap; src: url("ShipporiMinchoB1-ExtraBold.woff2") format("woff2"); } * { box-sizing: border-box; } html, body { margin: 0; } diff --git a/app.js b/app.js index 841ac8e..9d292ea 100644 --- a/app.js +++ b/app.js @@ -1544,6 +1544,25 @@ try { await api('PATCH', '/settings', { log: { ...s.log, level: e.target.value } }); s.log.level = e.target.value; toast('Log level: ' + e.target.value); } catch (x) { toast(x.message, true); } } }, ['debug', 'info', 'warn', 'error'].map((l) => h('option', { value: l, selected: s.log.level === l }, l))); + // decoy + const decoyPages = [['nginx', 'nginx welcome page'], ['apache', 'Apache "It works!" page'], ['soon', '"Coming soon" page']]; + const decoyBox = h('input', { type: 'checkbox', id: 'dc', checked: s.decoy.enabled, onChange: async (e) => { + const on = e.target.checked; + if (on) { + const hasApp = (tk.tokens || []).some((t) => t.scope === 'rw'); + if (!await confirmDialog({ title: 'Turn on Decoy?', ok: 'Turn on', danger: true, + text: 'The web interface disappears right away and the server shows the decoy page instead. Only the iOS app can turn Decoy off again.' + + (hasApp ? '' : ' No iOS app with full access is paired yet, so you could not get the web interface back.') })) { + e.target.checked = false; + return; + } + } + try { await api('PATCH', '/settings', { decoy: { ...s.decoy, enabled: on } }); s.decoy.enabled = on; toast(on ? 'Decoy on. This tab keeps working until you close or reload it' : 'Decoy off'); } catch (x) { e.target.checked = !on; toast(x.message, true); } + } }); + const decoySel = h('select', { id: 'dp', onChange: async (e) => { + try { await api('PATCH', '/settings', { decoy: { ...s.decoy, page: e.target.value } }); s.decoy.page = e.target.value; toast('Decoy page saved'); } catch (x) { e.target.value = s.decoy.page; toast(x.message, true); } + } }, decoyPages.map(([v, t]) => h('option', { value: v, selected: s.decoy.page === v }, t))); + // data retention const presetSelect = (id, value, presets, unit) => { const opts = presets.some(([v]) => v === value) ? presets : [...presets, [value, value + ' ' + unit]].sort((a, b) => a[0] - b[0]); @@ -1621,6 +1640,14 @@ webErr, h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Save'))), + h('section', { class: 'card', 'aria-labelledby': 'dcy' }, + h('h2', { id: 'dcy' }, 'Decoy'), + h('p', { class: 'lead' }, 'Shows an ordinary web server page instead of this interface. The iOS app and setup links keep working. Changes apply immediately.'), + h('label', { class: 'check' }, decoyBox, h('span', null, 'Decoy', h('br'), + h('span', { class: 'hint' }, 'Hides the web interface. Turn it off again in the iOS app.'))), + h('div', { class: 'grid section' }, + h('div', { class: 'field' }, h('label', { htmlFor: 'dp' }, 'Decoy page'), decoySel))), + h('section', { class: 'card', 'aria-labelledby': 'api' }, h('div', { class: 'cardhead' }, h('div', null, h('h2', { id: 'api' }, 'API tokens'), h('p', { class: 'lead', style: { marginBottom: '0' } }, 'For the iOS app and scripts. A token appears once when you create it, and only a hash is stored.')), diff --git a/config.go b/config.go index 1bdca66..3ce8384 100644 --- a/config.go +++ b/config.go @@ -32,6 +32,14 @@ type Config struct { Peers []Peer `json:"peers"` Log LogConfig `json:"log"` Stats StatsConfig `json:"stats"` + Decoy DecoyConfig `json:"decoy"` +} + +// DecoyConfig replaces the web interface with a stock web server page. +// The API keeps working, so the iOS app can turn it off again. +type DecoyConfig struct { + Enabled bool `json:"enabled"` + Page string `json:"page"` // nginx | apache | soon } // StatsConfig sets how long traffic history is kept in stats.json. @@ -223,6 +231,9 @@ func (c *Config) applyDefaults() { if c.Stats.DailyDays == 0 { c.Stats.DailyDays = 400 } + if c.Decoy.Page == "" { + c.Decoy.Page = "nginx" + } if c.APITokens == nil { c.APITokens = []APIToken{} } @@ -356,6 +367,9 @@ func (c *Config) validate() error { } else if st.DailyDays < minDailyDays || st.DailyDays > maxDailyDays { return fmt.Errorf("daily traffic history must be %d–%d days", minDailyDays, maxDailyDays) } + if _, ok := decoyPages[c.Decoy.Page]; !ok { + return fmt.Errorf("unknown decoy page %q", c.Decoy.Page) + } switch c.Web.TLS.Mode { case "acme": if c.Web.TLS.Domain == "" { diff --git a/decoy.go b/decoy.go new file mode 100644 index 0000000..e7f7894 --- /dev/null +++ b/decoy.go @@ -0,0 +1,539 @@ +package main + +import ( + "html" + "net" + "net/http" + "strings" +) + +// A decoy answers every web path like a freshly installed web server: the +// front page is its stock welcome page and everything else is its stock +// error page. Only /api/v1 and live setup links get past it. +type decoyPage struct { + server string // Server header, "" for none + index func(host string) string // the front page + error func(code int, r *http.Request) string // body for 404 and 405 +} + +var decoyPages = map[string]decoyPage{ + "nginx": {server: nginxServer, index: func(string) string { return nginxIndex }, error: nginxError}, + "apache": {server: apacheServer, index: func(string) string { return apacheIndex }, error: apacheError}, + "soon": {index: soonIndex, error: soonError}, +} + +// serveDecoy writes the decoy's answer for r. It drops the headers the web +// interface adds, since a stock server sends none of them. +func serveDecoy(w http.ResponseWriter, r *http.Request, name string) { + d, ok := decoyPages[name] + if !ok { + d = decoyPages["nginx"] + } + h := w.Header() + for _, k := range []string{"Content-Security-Policy", "X-Content-Type-Options", "Referrer-Policy", "X-Frame-Options", "Strict-Transport-Security", "Cache-Control"} { + h.Del(k) + } + if d.server != "" { + h.Set("Server", d.server) + } + h.Set("Content-Type", "text/html") + code, body := http.StatusOK, "" + switch { + case r.Method != http.MethodGet && r.Method != http.MethodHead: + code, body = http.StatusMethodNotAllowed, d.error(http.StatusMethodNotAllowed, r) + case r.URL.Path == "/" || r.URL.Path == "/index.html": + body = d.index(hostOnly(r.Host)) + default: + code, body = http.StatusNotFound, d.error(http.StatusNotFound, r) + } + w.WriteHeader(code) + if r.Method != http.MethodHead { + _, _ = w.Write([]byte(body)) + } +} + +func hostOnly(hostport string) string { + if h, _, err := net.SplitHostPort(hostport); err == nil { + return h + } + return hostport +} + +func hostPort(r *http.Request) string { + if _, p, err := net.SplitHostPort(r.Host); err == nil { + return p + } + if r.TLS != nil { + return "443" + } + return "80" +} + +const nginxServer = "nginx/1.24.0 (Ubuntu)" + +const nginxIndex = ` + + +Welcome to nginx! + + + +

Welcome to nginx!

+

If you see this page, the nginx web server is successfully installed and +working. Further configuration is required.

+ +

For online documentation and support please refer to +nginx.org.
+Commercial support is available at +nginx.com.

+ +

Thank you for using nginx.

+ + +` + +func nginxError(code int, _ *http.Request) string { + status := statusLine(code) + return "\r\n" + status + "\r\n\r\n

" + status + + "

\r\n
" + nginxServer + "
\r\n\r\n\r\n" +} + +const apacheServer = "Apache/2.4.58 (Ubuntu)" + +func apacheError(code int, r *http.Request) string { + msg := "

The requested URL was not found on this server.

" + if code == http.StatusMethodNotAllowed { + msg = "

The requested method " + html.EscapeString(r.Method) + " is not allowed for this URL.

" + } + return "\n\n" + statusLine(code) + + "\n\n

" + http.StatusText(code) + "

\n" + msg + "\n
\n
" + apacheServer + + " Server at " + html.EscapeString(hostOnly(r.Host)) + " Port " + hostPort(r) + "
\n\n" +} + +func soonIndex(host string) string { + return strings.ReplaceAll(soonTemplate, "{{host}}", html.EscapeString(host)) +} + +func soonError(code int, _ *http.Request) string { + status := statusLine(code) + return "\n\n" + status + "\n\n

" + status + "

\n\n\n" +} + +func statusLine(code int) string { + if code == http.StatusMethodNotAllowed { + return "405 Not Allowed" // nginx's wording, also fine for the others + } + return "404 Not Found" +} + +const soonTemplate = ` + + + + +Coming soon + + + +
+

Coming soon

+

This site is under construction.

+

{{host}}

+
+ + +` + +const apacheIndex = ` + + + + Apache2 Ubuntu Default Page: It works + + + +
+ + +
+ + +
+
+ It works! +
+
+

+ This is the default welcome page used to test the correct + operation of the Apache2 server after installation on Ubuntu systems. + It is based on the equivalent page on Debian, from which the Ubuntu Apache + packaging is derived. + If you can read this page, it means that the Apache HTTP server installed at + this site is working properly. You should replace this file (located at + /var/www/html/index.html) before continuing to operate your HTTP server. +

+ + +

+ If you are a normal user of this web site and don't know what this page is + about, this probably means that the site is currently unavailable due to + maintenance. + If the problem persists, please contact the site's administrator. +

+ +
+
+
+ Configuration Overview +
+
+

+ Ubuntu's Apache2 default configuration is different from the + upstream default configuration, and split into several files optimized for + interaction with Ubuntu tools. The configuration system is + fully documented in + /usr/share/doc/apache2/README.Debian.gz. Refer to this for the full + documentation. Documentation for the web server itself can be + found by accessing the manual if the apache2-doc + package was installed on this server. +

+

+ The configuration layout for an Apache2 web server installation on Ubuntu systems is as follows: +

+
+/etc/apache2/
+|-- apache2.conf
+|       ` + "`" + `--  ports.conf
+|-- mods-enabled
+|       |-- *.load
+|       ` + "`" + `-- *.conf
+|-- conf-enabled
+|       ` + "`" + `-- *.conf
+|-- sites-enabled
+|       ` + "`" + `-- *.conf
+          
+
    +
  • + apache2.conf is the main configuration + file. It puts the pieces together by including all remaining configuration + files when starting up the web server. +
  • + +
  • + ports.conf is always included from the + main configuration file. It is used to determine the listening ports for + incoming connections, and this file can be customized anytime. +
  • + +
  • + Configuration files in the mods-enabled/, + conf-enabled/ and sites-enabled/ directories contain + particular configuration snippets which manage modules, global configuration + fragments, or virtual host configurations, respectively. +
  • + +
  • + They are activated by symlinking available + configuration files from their respective + *-available/ counterparts. These should be managed + by using our helpers + + a2enmod, + a2dismod, + + + a2ensite, + a2dissite, + + and + + a2enconf, + a2disconf + . See their respective man pages for detailed information. +
  • + +
  • + The binary is called apache2 and is managed using systemd, so to + start/stop the service use systemctl start apache2 and + systemctl stop apache2, and use systemctl status apache2 + and journalctl -u apache2 to check status. system + and apache2ctl can also be used for service management if + desired. + Calling /usr/bin/apache2 directly will not work with the + default configuration. +
  • +
+
+ +
+
+ Document Roots +
+ +
+

+ By default, Ubuntu does not allow access through the web browser to + any file outside of those located in /var/www, + public_html + directories (when enabled) and /usr/share (for web + applications). If your site is using a web document root + located elsewhere (such as in /srv) you may need to whitelist your + document root directory in /etc/apache2/apache2.conf. +

+

+ The default Ubuntu document root is /var/www/html. You + can make your own virtual hosts under /var/www. +

+
+ +
+
+ Reporting Problems +
+
+

+ Please use the ubuntu-bug tool to report bugs in the + Apache2 package with Ubuntu. However, check existing bug reports before reporting a new bug. +

+

+ Please report bugs specific to modules (such as PHP and others) + to their respective packages, not to the web server itself. +

+
+ + + + +
+
+
+
+ + +` diff --git a/main_test.go b/main_test.go index e96a920..7b1f89c 100644 --- a/main_test.go +++ b/main_test.go @@ -877,3 +877,78 @@ func TestUsers(t *testing.T) { } admin("PATCH", "/settings", map[string]any{"adminUsername": "x"}, 400) } + +// TestDecoy checks that the decoy hides the web interface but leaves the API +// and live setup links alone. +func TestDecoy(t *testing.T) { + dir := t.TempDir() + store, err := openStore(filepath.Join(dir, "config.json")) + if err != nil { + t.Fatal(err) + } + if store.Get().Decoy.Page != "nginx" { + t.Fatalf("default decoy page %q", store.Get().Decoy.Page) + } + k := &fakeKernel{} + st, _ := openStats(filepath.Join(dir, "stats.json"), store, k) + app := &App{store: store, kernel: k, recon: newReconciler(k, store), stats: st, auth: newAuth(store), + tls: &webTLS{}, logPath: filepath.Join(dir, "log.jsonl"), started: time.Now(), shutdown: func() {}} + srv := httptest.NewServer(app.routes()) + defer srv.Close() + + get := func(path string, want int) (string, http.Header) { + t.Helper() + resp, err := http.Get(srv.URL + path) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + if resp.StatusCode != want { + t.Fatalf("GET %s: status %d, want %d", path, resp.StatusCode, want) + } + return string(b), resp.Header + } + set := func(fn func(c *Config)) { + if err := store.Update(func(c *Config) error { fn(c); return nil }); err != nil { + t.Fatal(err) + } + } + + if b, _ := get("/", 200); !strings.Contains(b, "/app.js") { + t.Fatal("web interface not served with the decoy off") + } + set(func(c *Config) { + v4 := netip.MustParsePrefix(c.Server.IPv4) + c.Peers = append(c.Peers, Peer{ID: "p1", Name: "phone", IPv4: v4.Addr().Next().Next().Next().String(), Setup: &SetupLink{Token: "live-token", Expires: time.Now().Add(time.Hour)}}) + c.Decoy.Enabled = true + }) + + b, h := get("/", 200) + if !strings.Contains(b, "Welcome to nginx!") || h.Get("Server") != nginxServer || h.Get("Content-Security-Policy") != "" { + t.Fatalf("nginx decoy: %q %v", b, h) + } + for _, p := range []string{"/app.js", "/app.css", "/favicon.svg", "/ShipporiMinchoB1-ExtraBold.woff2", "/setup/wrong", "/setup/wrong/app.css", "/setup/live-token/app.js"} { + if b, _ := get(p, 404); strings.Contains(b, "GHOSTWIRE") || !strings.Contains(b, "404 Not Found") { + t.Fatalf("%s leaks: %q", p, b) + } + } + if b, _ := get("/setup/live-token", 200); !strings.Contains(b, `src="/setup/live-token/setup.js"`) { + t.Fatalf("setup page files not under the link: %q", b) + } + get("/setup/live-token/app.css", 200) + get("/api/v1/setup/live-token", 200) + get("/api/v1/status", 401) + + set(func(c *Config) { c.Decoy.Page = "apache" }) + if b, _ := get("/nope", 404); !strings.Contains(b, "Apache/2.4.58 (Ubuntu) Server at 127.0.0.1 Port") { + t.Fatalf("apache 404: %q", b) + } + set(func(c *Config) { c.Decoy.Page = "soon" }) + if b, h := get("/", 200); !strings.Contains(b, "

127.0.0.1

") || h.Get("Server") != "" { + t.Fatalf("soon decoy: %q", b) + } + if err := store.Update(func(c *Config) error { c.Decoy.Page = "iis"; return nil }); err == nil { + t.Fatal("unknown decoy page accepted") + } +} diff --git a/web.go b/web.go index bfc9329..1406392 100644 --- a/web.go +++ b/web.go @@ -3,6 +3,9 @@ package main import ( "embed" "net/http" + "net/url" + "strings" + "time" ) // The web UI and its icons are built into the binary. The UI talks only to @@ -11,9 +14,13 @@ import ( //go:embed index.html setup.html app.js setup.js app.css favicon.svg apple-touch-icon.png ShipporiMinchoB1-ExtraBold.woff2 var webFiles embed.FS -func webHandler() http.Handler { +func (a *App) webHandler() http.Handler { files := http.FileServerFS(webFiles) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if d := a.store.Get().Decoy; d.Enabled { + serveDecoy(w, r, d.Page) + return + } switch r.URL.Path { case "/", "/app.js", "/setup.js", "/app.css", "/favicon.svg", "/apple-touch-icon.png": w.Header().Set("Cache-Control", "no-cache") @@ -30,15 +37,50 @@ func webHandler() http.Handler { }) } +// setupAllowed reports whether the setup page and its files may be served for +// this token. With the decoy on, only a live setup link gets past the decoy. +func (a *App) setupAllowed(token string) bool { + cfg := a.store.Get() + if !cfg.Decoy.Enabled { + return true + } + p := cfg.peerByToken(token) + return p != nil && !p.Setup.expired(time.Now()) +} + // setupPage serves the page a setup link opens. The token stays in the URL; -// setup.js reads it from there and talks to /api/v1/setup. -func setupPage(w http.ResponseWriter, r *http.Request) { +// setup.js reads it from there and talks to /api/v1/setup. The page loads its +// files from under the link, so they work while the decoy hides the root. +func (a *App) setupPage(w http.ResponseWriter, r *http.Request) { + token := r.PathValue("token") + if !a.setupAllowed(token) { + serveDecoy(w, r, a.store.Get().Decoy.Page) + return + } b, err := webFiles.ReadFile("setup.html") if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } + base := "/setup/" + url.PathEscape(token) + "/" + page := strings.NewReplacer(`href="/`, `href="`+base, `src="/`, `src="`+base).Replace(string(b)) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") - _, _ = w.Write(b) + _, _ = w.Write([]byte(page)) +} + +func (a *App) setupAsset(w http.ResponseWriter, r *http.Request) { + file := r.PathValue("file") + switch file { + case "setup.js", "app.css", "favicon.svg", "apple-touch-icon.png", "ShipporiMinchoB1-ExtraBold.woff2": + default: + a.webHandler().ServeHTTP(w, r) + return + } + if !a.setupAllowed(r.PathValue("token")) { + serveDecoy(w, r, a.store.Get().Decoy.Page) + return + } + w.Header().Set("Cache-Control", "no-store") + http.ServeFileFS(w, r, webFiles, file) }