6 Commits

Author SHA1 Message Date
Daniel Redetzke 04a1d1ab85 Full-access tokens manage users, passwords and tokens 2026-10-04 20:37:46 +03:00
Daniel Redetzke ac2ce23613 Sign out from an icon in the account row 2026-10-04 20:21:00 +03:00
Daniel Redetzke fe71b0b6c2 Decoy setting 2026-10-04 20:16:57 +03:00
Daniel Redetzke 606b3fe89f Keep the sidebar in view on long pages
Beside the page, the sidebar now stays in place while the page scrolls,
so the account link and Sign out are always visible; it scrolls by
itself in very short windows. On a phone it still stacks above the page.
Also show the version with one 'v': release builds already start with it.
2026-10-04 16:36:30 +03:00
Daniel Redetzke c7340d011a README: screenshots of the web interface
Seven pages captured from a demo instance with sample data from the
simulator: dashboard (under the introduction), peers, peer, server,
settings, my account and sign-in. Images live in screenshots/.
2026-10-04 16:31:01 +03:00
Daniel Redetzke 9220ff54aa README: show the Hannya logo at the top 2026-10-04 16:14:54 +03:00
15 changed files with 782 additions and 27 deletions
+24 -3
View File
@@ -1,3 +1,7 @@
<p align="center">
<img src="favicon.svg" width="120" height="120" alt="GHOSTWIRE logo: the Hannya mask">
</p>
# GHOSTWIRE
**ゴーストワイヤー** · A self-hosted WireGuard server manager in a single Go
@@ -8,6 +12,8 @@ remove), hands out client configs as a download or QR code, and records traffic
and connection history per peer. There are no install scripts and no
dependencies on the server: the binary installs, updates and removes itself.
![Dashboard with peers online, traffic of the last 24 hours, the peer list and recent activity](screenshots/dashboard.png)
## Features
- **One file of state:** everything lives in `config.json`. The kernel is
@@ -34,6 +40,19 @@ dependencies on the server: the binary installs, updates and removes itself.
- **HTTPS built in:** Let's Encrypt, a self-signed certificate, your own
certificate files, or plain HTTP behind a reverse proxy.
## Screenshots
| | |
|---|---|
| ![Peers list with status, endpoint, latency sparklines and 30-day traffic](screenshots/peers.png) | ![Peer page with traffic and latency charts, connection details and history](screenshots/peer.png) |
| **Peers:** status, endpoint, latency and traffic at a glance | **Peer:** traffic, latency, connection history and settings |
| ![Server page with health checks, interface, endpoint, client defaults and firewall](screenshots/server.png) | ![Settings with users, web interface and API tokens](screenshots/settings.png) |
| **Server:** health, address plan, client defaults and firewall | **Settings:** users, web interface and API tokens |
| ![My account page with profile, password and own app tokens](screenshots/account.png) | ![Sign-in page](screenshots/login.png) |
| **My account:** profile, password and your app tokens | **Sign-in** |
The screenshots show sample data from the built-in simulator.
## Security
- **Client private keys are never stored.** A config is shown once, as a
@@ -212,8 +231,9 @@ Base path `/api/v1`. The web interface signs in with a session cookie; every
user is an admin. Apps and scripts use `Authorization: Bearer <token>`; create
the token under Settings → Pair iOS app. A token belongs to the user who made
it and is revoked when that user is deleted. A read-only token may only use
GET. Full-access tokens can do everything the web interface does except the
endpoints marked "signed in": users, passwords, API tokens, backup and restore.
GET. Full-access tokens can do everything the web interface does except backup
and restore. Users, passwords and API tokens need a full-access token even for
reading.
`POST /users` and `POST /users/{id}/reset-password` take
`{"password": "…", "mustChangePassword": true}`; with `true` (the default) the
@@ -233,7 +253,8 @@ GET /peers/{id}/latency (24 h, one point per 5 minutes)
GET /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup
GET /settings PATCH /settings POST /restart
GET /logs?level=&limit=&audit=1 GET /logs/download
signed in: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore
GET /tokens POST /tokens DELETE /tokens/{id}
signed in: GET /backup · POST /restore
public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens)
```
+34 -13
View File
@@ -92,6 +92,17 @@ func (a *App) guard(adminOnly bool, h http.HandlerFunc) http.HandlerFunc {
}
}
// fullAccess refuses read-only tokens, also for GET.
func fullAccess(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if who(r).Scope == "ro" {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "this token is read-only"})
return
}
h(w, r)
}
}
// applyResult saves-then-applies: the config is already stored, so a kernel
// error is reported but does not undo the change.
func (a *App) apply() string {
@@ -105,16 +116,18 @@ func (a *App) routes() http.Handler {
mux := http.NewServeMux()
g := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(false, h)) }
adm := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(true, h)) }
// full is for signed-in users and full-access tokens, even for reading.
full := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(false, fullAccess(h))) }
mux.HandleFunc("POST /api/v1/auth/login", a.login)
mux.HandleFunc("POST /api/v1/auth/logout", a.logout)
g("GET /api/v1/auth/me", a.me)
adm("POST /api/v1/auth/password", a.changePassword)
adm("GET /api/v1/users", a.listUsers)
adm("POST /api/v1/users", a.createUser)
adm("PATCH /api/v1/users/{id}", a.patchUser)
adm("POST /api/v1/users/{id}/reset-password", a.resetPassword)
adm("DELETE /api/v1/users/{id}", a.deleteUser)
full("POST /api/v1/auth/password", a.changePassword)
full("GET /api/v1/users", a.listUsers)
full("POST /api/v1/users", a.createUser)
full("PATCH /api/v1/users/{id}", a.patchUser)
full("POST /api/v1/users/{id}/reset-password", a.resetPassword)
full("DELETE /api/v1/users/{id}", a.deleteUser)
g("GET /api/v1/status", a.status)
g("GET /api/v1/stats", a.allStats)
@@ -143,14 +156,14 @@ func (a *App) routes() http.Handler {
mux.HandleFunc("GET /api/v1/setup/{token}", a.setupInfo)
mux.HandleFunc("POST /api/v1/setup/{token}", a.setupRedeem)
// Full-access tokens (the iOS app) may change app settings and read logs.
// Users, passwords, tokens and backups need a signed-in user.
// Full-access tokens (the iOS app) may change app settings, read logs and
// manage users and tokens. Backups need a signed-in user.
g("GET /api/v1/settings", a.getSettings)
g("PATCH /api/v1/settings", a.patchSettings)
g("POST /api/v1/restart", a.restart)
adm("GET /api/v1/tokens", a.listTokens)
adm("POST /api/v1/tokens", a.createToken)
adm("DELETE /api/v1/tokens/{id}", a.deleteToken)
full("GET /api/v1/tokens", a.listTokens)
full("POST /api/v1/tokens", a.createToken)
full("DELETE /api/v1/tokens/{id}", a.deleteToken)
g("GET /api/v1/logs", a.logs)
g("GET /api/v1/logs/download", a.downloadLog)
adm("GET /api/v1/backup", a.backup)
@@ -159,8 +172,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))
@@ -225,6 +239,9 @@ func (a *App) me(w http.ResponseWriter, r *http.Request) {
"id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope,
"mustChangePassword": p.MustChangePassword, "version": version, "session": p.Session,
}
if p.TokenID != "" {
out["tokenId"] = p.TokenID // lets an app find its own token in /tokens
}
if _, u := a.store.Get().userByID(p.UserID); u != nil {
out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created
}
@@ -963,6 +980,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 +1009,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 {
+10 -2
View File
@@ -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; }
@@ -56,7 +56,12 @@ a.brand, a.brand:hover { color: #fff; text-decoration: none; }
.side a.nav.on { background: #2a2b31; color: #fff; }
.side .count { margin-left: auto; font-size: 12px; color: #8d8e93; }
.side .foot { margin-top: auto; padding-top: 16px; border-top: 1px solid #2c2d32; display: flex; flex-direction: column; gap: 2px; font-size: 12px; color: #8d8e93; }
.side .foot button { background: none; border: 0; padding: 0; font: inherit; color: #c9c9c3; text-decoration: underline; cursor: pointer; }
.side .acctrow { display: flex; align-items: center; gap: 4px; }
.side .acctrow .acct { flex: 1; min-width: 0; }
.side .signout { position: relative; flex: none; width: 40px; height: 40px; display: grid; place-items: center; border: 0; border-radius: 8px; background: none; color: #8d8e93; cursor: pointer; }
.side .signout:hover { background: #222328; color: #fff; }
.side .signout .tip { position: absolute; bottom: calc(100% + 6px); right: 0; padding: 3px 8px; border-radius: 5px; background: #000; color: #fff; font-size: 12px; white-space: nowrap; opacity: 0; pointer-events: none; transition: opacity 0.12s; }
.side .signout:hover .tip, .side .signout:focus-visible .tip { opacity: 1; }
.side .acct { display: flex; align-items: center; gap: 12px; min-height: 52px; padding: 0 12px; border-radius: 8px; color: #c9c9c3; text-decoration: none; }
.side .acct:hover { background: #222328; color: #fff; }
.side .acct.on { background: #2a2b31; color: #fff; }
@@ -66,6 +71,9 @@ a.brand, a.brand:hover { color: #fff; text-decoration: none; }
.side .avatar { width: 28px; height: 28px; border-radius: 50%; background: #3a3b41; display: grid; place-items: center; flex: none; font-size: 13px; font-weight: 600; color: #fff; }
.side .footrow { display: flex; justify-content: space-between; padding: 10px 12px 0; }
.main { flex: 999 1 560px; min-width: 0; padding: 32px 32px 56px; }
/* Beside the page (not stacked above it on a phone), the sidebar stays in
place while the page scrolls, so the account link is always visible. */
@media (min-width: 800px) { .side { position: sticky; top: 0; height: 100vh; height: 100dvh; overflow-y: auto; } }
.wrap { max-width: 1120px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
@media (max-width: 640px) { .main { padding: 20px 16px 40px; } }
+31 -2
View File
@@ -48,6 +48,7 @@
server: '<rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01"/>',
settings: '<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/>',
plus: '<path d="M12 5v14M5 12h14"/>',
logout: '<path d="M14 4h4a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2h-4"/><path d="M10 16l-4-4 4-4M6 12h10"/>',
};
// The Hannya mark: the horned demon mask of Noh. Same drawing as favicon.svg.
@@ -513,12 +514,13 @@
srvBox,
NAV.map(([href, ic, label]) => (navLinks[href] = h('a', { class: 'nav', href }, icon(ic), label, ic === 'peers' ? peerCount : null))),
h('div', { class: 'foot' },
h('div', { class: 'acctrow' },
(navLinks['#/account'] = h('a', { class: 'acct', href: '#/account' },
h('span', { class: 'avatar', 'aria-hidden': 'true' }, me.name.slice(0, 1).toUpperCase()),
h('span', null, h('strong', null, me.name), h('span', null, 'My account')))),
h('button', { type: 'button', class: 'signout', 'aria-label': 'Sign out', onClick: logout }, icon('logout'), h('span', { class: 'tip', 'aria-hidden': 'true' }, 'Sign out'))),
h('div', { class: 'footrow' },
h('button', { type: 'button', onClick: logout }, 'Sign out'),
h('span', null, 'v' + me.version))));
h('span', null, 'v' + me.version.replace(/^v/, '')))));
main = h('main', { class: 'main', id: 'main' });
app.replaceChildren(h('div', { class: 'shell' }, nav, main));
refreshSide();
@@ -1544,6 +1546,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 +1642,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.')),
+14
View File
@@ -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 == "" {
+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>
`
+81
View File
@@ -328,6 +328,12 @@ func TestAPI(t *testing.T) {
bearer("GET", "/tokens", 403)
bearer("GET", "/peers/"+id+"/setup", 403) // the link would set up a device
// A full-access token manages users and tokens, but not backups.
secret = call("POST", "/tokens", map[string]string{"name": "full", "scope": "rw"}, 201)["token"].(string)
bearer("GET", "/users", 200)
bearer("GET", "/tokens", 200)
bearer("GET", "/backup", 403)
call("DELETE", "/peers/"+id, nil, 200)
if len(store.Get().Peers) != 0 {
t.Fatal("peer not deleted")
@@ -877,3 +883,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")
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

+46 -4
View File
@@ -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)
}