Coming soon
+This site is under construction.
+{{host}}
+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 = ` + +
+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\nThe 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\nThis site is under construction.
+{{host}}
++ 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. +
+ ++ 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 ++
+ 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. +
++ 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. +
+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) }