Web interface setting

This commit is contained in:
Daniel Redetzke
2026-10-04 20:16:57 +03:00
parent 606b3fe89f
commit bbbef00329
7 changed files with 709 additions and 7 deletions
+7 -2
View File
@@ -159,8 +159,9 @@ func (a *App) routes() http.Handler {
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"}) writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such endpoint"})
}) })
mux.HandleFunc("GET /setup/{token}", setupPage) mux.HandleFunc("GET /setup/{token}", a.setupPage)
mux.Handle("/", webHandler()) mux.HandleFunc("GET /setup/{token}/{file}", a.setupAsset)
mux.Handle("/", a.webHandler())
csrf := http.NewCrossOriginProtection() csrf := http.NewCrossOriginProtection()
return securityHeaders(csrf.Handler(mux)) return securityHeaders(csrf.Handler(mux))
@@ -963,6 +964,7 @@ func (a *App) getSettings(w http.ResponseWriter, r *http.Request) {
"web": cfg.Web, "web": cfg.Web,
"log": cfg.Log, "log": cfg.Log,
"stats": cfg.Stats, "stats": cfg.Stats,
"decoy": cfg.Decoy,
"geo": a.geoStatus(), "geo": a.geoStatus(),
"adminUsername": a.username(cfg, who(r).UserID), // kept for older iOS app versions "adminUsername": a.username(cfg, who(r).UserID), // kept for older iOS app versions
"fingerprint": a.tls.Fingerprint(), "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 { if err := field(m, "stats", &c.Stats); err != nil {
return err return err
} }
if err := field(m, "decoy", &c.Decoy); err != nil {
return err
}
return field(m, "log", &c.Log) return field(m, "log", &c.Log)
}) })
if err != nil { if err != nil {
+1 -1
View File
@@ -28,7 +28,7 @@
--brand: "Shippori Mincho B1", "Hiragino Mincho ProN", "Yu Mincho", serif; --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; } * { box-sizing: border-box; }
html, body { margin: 0; } html, body { margin: 0; }
+27
View File
@@ -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); } 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))); } }, ['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 // data retention
const presetSelect = (id, value, presets, unit) => { 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]); const opts = presets.some(([v]) => v === value) ? presets : [...presets, [value, value + ' ' + unit]].sort((a, b) => a[0] - b[0]);
@@ -1621,6 +1640,14 @@
webErr, webErr,
h('div', { class: 'formfoot' }, h('button', { type: 'submit', class: 'btn primary' }, 'Save'))), 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('section', { class: 'card', 'aria-labelledby': 'api' },
h('div', { class: 'cardhead' }, 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.')), 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.')),
+14
View File
@@ -32,6 +32,14 @@ type Config struct {
Peers []Peer `json:"peers"` Peers []Peer `json:"peers"`
Log LogConfig `json:"log"` Log LogConfig `json:"log"`
Stats StatsConfig `json:"stats"` 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. // StatsConfig sets how long traffic history is kept in stats.json.
@@ -223,6 +231,9 @@ func (c *Config) applyDefaults() {
if c.Stats.DailyDays == 0 { if c.Stats.DailyDays == 0 {
c.Stats.DailyDays = 400 c.Stats.DailyDays = 400
} }
if c.Decoy.Page == "" {
c.Decoy.Page = "nginx"
}
if c.APITokens == nil { if c.APITokens == nil {
c.APITokens = []APIToken{} c.APITokens = []APIToken{}
} }
@@ -356,6 +367,9 @@ func (c *Config) validate() error {
} else if st.DailyDays < minDailyDays || st.DailyDays > maxDailyDays { } else if st.DailyDays < minDailyDays || st.DailyDays > maxDailyDays {
return fmt.Errorf("daily traffic history must be %d–%d days", minDailyDays, 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 { switch c.Web.TLS.Mode {
case "acme": case "acme":
if c.Web.TLS.Domain == "" { if c.Web.TLS.Domain == "" {
+539
View File
@@ -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 = `<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>
<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>
<p><em>Thank you for using nginx.</em></p>
</body>
</html>
`
func nginxError(code int, _ *http.Request) string {
status := statusLine(code)
return "<html>\r\n<head><title>" + status + "</title></head>\r\n<body>\r\n<center><h1>" + status +
"</h1></center>\r\n<hr><center>" + nginxServer + "</center>\r\n</body>\r\n</html>\r\n"
}
const apacheServer = "Apache/2.4.58 (Ubuntu)"
func apacheError(code int, r *http.Request) string {
msg := "<p>The requested URL was not found on this server.</p>"
if code == http.StatusMethodNotAllowed {
msg = "<p>The requested method " + html.EscapeString(r.Method) + " is not allowed for this URL.</p>"
}
return "<!DOCTYPE HTML PUBLIC \"-//IETF//DTD HTML 2.0//EN\">\n<html><head>\n<title>" + statusLine(code) +
"</title>\n</head><body>\n<h1>" + http.StatusText(code) + "</h1>\n" + msg + "\n<hr>\n<address>" + apacheServer +
" Server at " + html.EscapeString(hostOnly(r.Host)) + " Port " + hostPort(r) + "</address>\n</body></html>\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 "<!DOCTYPE html>\n<html>\n<head><title>" + status + "</title></head>\n<body>\n<h1>" + status + "</h1>\n</body>\n</html>\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 = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Coming soon</title>
<style>
html, body { height: 100%; margin: 0; }
body { display: flex; align-items: center; justify-content: center; background: #f7f7f7; color: #444;
font-family: Helvetica, Arial, sans-serif; text-align: center; }
h1 { font-size: 28px; font-weight: 600; color: #222; margin: 0 0 10px; }
p { margin: 0 0 6px; }
.host { font-size: 13px; color: #888; margin-top: 18px; }
</style>
</head>
<body>
<main>
<h1>Coming soon</h1>
<p>This site is under construction.</p>
<p class="host">{{host}}</p>
</main>
</body>
</html>
`
const apacheIndex = `<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>Apache2 Ubuntu Default Page: It works</title>
<style type="text/css" media="screen">
* {
margin: 0px 0px 0px 0px;
padding: 0px 0px 0px 0px;
}
body, html {
padding: 3px 3px 3px 3px;
background-color: #D8DBE2;
font-family: Ubuntu, Verdana, sans-serif;
font-size: 11pt;
text-align: center;
}
div.main_page {
position: relative;
display: table;
width: 800px;
margin-bottom: 3px;
margin-left: auto;
margin-right: auto;
padding: 0px 0px 0px 0px;
border-width: 2px;
border-color: #212738;
border-style: solid;
background-color: #FFFFFF;
text-align: center;
}
div.page_header {
height: 180px;
width: 100%;
background-color: #F5F6F7;
}
div.page_header span {
margin: 15px 0px 0px 50px;
font-size: 180%;
font-weight: bold;
}
div.page_header img {
margin: 3px 0px 0px 40px;
border: 0px 0px 0px;
}
div.banner {
padding: 9px 6px 9px 6px;
background-color: #E9510E;
color: #FFFFFF;
font-weight: bold;
font-size: 112%;
text-align: center;
position: absolute;
left: 40%;
bottom: 30px;
width: 20%;
}
div.table_of_contents {
clear: left;
min-width: 200px;
margin: 3px 3px 3px 3px;
background-color: #FFFFFF;
text-align: left;
}
div.table_of_contents_item {
clear: left;
width: 100%;
margin: 4px 0px 0px 0px;
background-color: #FFFFFF;
color: #000000;
text-align: left;
}
div.table_of_contents_item a {
margin: 6px 0px 0px 6px;
}
div.content_section {
margin: 3px 3px 3px 3px;
background-color: #FFFFFF;
text-align: left;
}
div.content_section_text {
padding: 4px 8px 4px 8px;
color: #000000;
font-size: 100%;
}
div.content_section_text pre {
margin: 8px 0px 8px 0px;
padding: 8px 8px 8px 8px;
border-width: 1px;
border-style: dotted;
border-color: #000000;
background-color: #F5F6F7;
font-style: italic;
}
div.content_section_text p {
margin-bottom: 6px;
}
div.content_section_text ul, div.content_section_text li {
padding: 4px 8px 4px 16px;
}
div.section_header {
padding: 3px 6px 3px 6px;
background-color: #8E9CB2;
color: #FFFFFF;
font-weight: bold;
font-size: 112%;
text-align: center;
}
div.section_header_red {
background-color: #CD214F;
}
div.section_header_grey {
background-color: #9F9386;
}
.floating_element {
position: relative;
float: left;
}
div.table_of_contents_item a,
div.content_section_text a {
text-decoration: none;
font-weight: bold;
}
div.table_of_contents_item a:link,
div.table_of_contents_item a:visited,
div.table_of_contents_item a:active {
color: #000000;
}
div.table_of_contents_item a:hover {
background-color: #000000;
color: #FFFFFF;
}
div.content_section_text a:link,
div.content_section_text a:visited,
div.content_section_text a:active {
background-color: #DCDFE6;
color: #000000;
}
div.content_section_text a:hover {
background-color: #000000;
color: #DCDFE6;
}
div.validator {
}
</style>
</head>
<body>
<div class="main_page">
<div class="page_header floating_element">
<span class="floating_element">
Apache2 Default Page
</span>
</div>
<!-- <div class="table_of_contents floating_element">
<div class="section_header section_header_grey">
TABLE OF CONTENTS
</div>
<div class="table_of_contents_item floating_element">
<a href="#about">About</a>
</div>
<div class="table_of_contents_item floating_element">
<a href="#changes">Changes</a>
</div>
<div class="table_of_contents_item floating_element">
<a href="#scope">Scope</a>
</div>
<div class="table_of_contents_item floating_element">
<a href="#files">Config files</a>
</div>
</div>
-->
<div class="content_section floating_element">
<div class="section_header section_header_red">
<div id="about"></div>
It works!
</div>
<div class="content_section_text">
<p>
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 <b>replace this file</b> (located at
<tt>/var/www/html/index.html</tt>) before continuing to operate your HTTP server.
</p>
<p>
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.
</p>
</div>
<div class="section_header">
<div id="changes"></div>
Configuration Overview
</div>
<div class="content_section_text">
<p>
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
<b>fully documented in
/usr/share/doc/apache2/README.Debian.gz</b>. Refer to this for the full
documentation. Documentation for the web server itself can be
found by accessing the <a href="/manual">manual</a> if the <tt>apache2-doc</tt>
package was installed on this server.
</p>
<p>
The configuration layout for an Apache2 web server installation on Ubuntu systems is as follows:
</p>
<pre>
/etc/apache2/
|-- apache2.conf
| ` + "`" + `-- ports.conf
|-- mods-enabled
| |-- *.load
| ` + "`" + `-- *.conf
|-- conf-enabled
| ` + "`" + `-- *.conf
|-- sites-enabled
| ` + "`" + `-- *.conf
</pre>
<ul>
<li>
<tt>apache2.conf</tt> is the main configuration
file. It puts the pieces together by including all remaining configuration
files when starting up the web server.
</li>
<li>
<tt>ports.conf</tt> 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.
</li>
<li>
Configuration files in the <tt>mods-enabled/</tt>,
<tt>conf-enabled/</tt> and <tt>sites-enabled/</tt> directories contain
particular configuration snippets which manage modules, global configuration
fragments, or virtual host configurations, respectively.
</li>
<li>
They are activated by symlinking available
configuration files from their respective
*-available/ counterparts. These should be managed
by using our helpers
<tt>
a2enmod,
a2dismod,
</tt>
<tt>
a2ensite,
a2dissite,
</tt>
and
<tt>
a2enconf,
a2disconf
</tt>. See their respective man pages for detailed information.
</li>
<li>
The binary is called apache2 and is managed using systemd, so to
start/stop the service use <tt>systemctl start apache2</tt> and
<tt>systemctl stop apache2</tt>, and use <tt>systemctl status apache2</tt>
and <tt>journalctl -u apache2</tt> to check status. <tt>system</tt>
and <tt>apache2ctl</tt> can also be used for service management if
desired.
<b>Calling <tt>/usr/bin/apache2</tt> directly will not work</b> with the
default configuration.
</li>
</ul>
</div>
<div class="section_header">
<div id="docroot"></div>
Document Roots
</div>
<div class="content_section_text">
<p>
By default, Ubuntu does not allow access through the web browser to
<em>any</em> file outside of those located in <tt>/var/www</tt>,
<a href="http://httpd.apache.org/docs/2.4/mod/mod_userdir.html" rel="nofollow">public_html</a>
directories (when enabled) and <tt>/usr/share</tt> (for web
applications). If your site is using a web document root
located elsewhere (such as in <tt>/srv</tt>) you may need to whitelist your
document root directory in <tt>/etc/apache2/apache2.conf</tt>.
</p>
<p>
The default Ubuntu document root is <tt>/var/www/html</tt>. You
can make your own virtual hosts under /var/www.
</p>
</div>
<div class="section_header">
<div id="bugs"></div>
Reporting Problems
</div>
<div class="content_section_text">
<p>
Please use the <tt>ubuntu-bug</tt> tool to report bugs in the
Apache2 package with Ubuntu. However, check <a
href="https://bugs.launchpad.net/ubuntu/+source/apache2"
rel="nofollow">existing bug reports</a> before reporting a new bug.
</p>
<p>
Please report bugs specific to modules (such as PHP and others)
to their respective packages, not to the web server itself.
</p>
</div>
</div>
</div>
<div class="validator">
</div>
</body>
</html>
`
+75
View File
@@ -877,3 +877,78 @@ func TestUsers(t *testing.T) {
} }
admin("PATCH", "/settings", map[string]any{"adminUsername": "x"}, 400) 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, "<p class=\"host\">127.0.0.1</p>") || 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")
}
}
+46 -4
View File
@@ -3,6 +3,9 @@ package main
import ( import (
"embed" "embed"
"net/http" "net/http"
"net/url"
"strings"
"time"
) )
// The web UI and its icons are built into the binary. The UI talks only to // 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 //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 var webFiles embed.FS
func webHandler() http.Handler { func (a *App) webHandler() http.Handler {
files := http.FileServerFS(webFiles) files := http.FileServerFS(webFiles)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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 { switch r.URL.Path {
case "/", "/app.js", "/setup.js", "/app.css", "/favicon.svg", "/apple-touch-icon.png": case "/", "/app.js", "/setup.js", "/app.css", "/favicon.svg", "/apple-touch-icon.png":
w.Header().Set("Cache-Control", "no-cache") 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; // 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. // setup.js reads it from there and talks to /api/v1/setup. The page loads its
func setupPage(w http.ResponseWriter, r *http.Request) { // 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") b, err := webFiles.ReadFile("setup.html")
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return 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("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store") 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)
} }