Web interface setting
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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.')),
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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>
|
||||
`
|
||||
@@ -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, "<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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user