Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04a1d1ab85 | |||
| ac2ce23613 | |||
| fe71b0b6c2 | |||
| 606b3fe89f | |||
| c7340d011a | |||
| 9220ff54aa |
@@ -1,3 +1,7 @@
|
|||||||
|
<p align="center">
|
||||||
|
<img src="favicon.svg" width="120" height="120" alt="GHOSTWIRE logo: the Hannya mask">
|
||||||
|
</p>
|
||||||
|
|
||||||
# GHOSTWIRE
|
# GHOSTWIRE
|
||||||
|
|
||||||
**ゴーストワイヤー** · A self-hosted WireGuard server manager in a single Go
|
**ゴーストワイヤー** · 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
|
and connection history per peer. There are no install scripts and no
|
||||||
dependencies on the server: the binary installs, updates and removes itself.
|
dependencies on the server: the binary installs, updates and removes itself.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **One file of state:** everything lives in `config.json`. The kernel is
|
- **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
|
- **HTTPS built in:** Let's Encrypt, a self-signed certificate, your own
|
||||||
certificate files, or plain HTTP behind a reverse proxy.
|
certificate files, or plain HTTP behind a reverse proxy.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
|  |  |
|
||||||
|
| **Peers:** status, endpoint, latency and traffic at a glance | **Peer:** traffic, latency, connection history and settings |
|
||||||
|
|  |  |
|
||||||
|
| **Server:** health, address plan, client defaults and firewall | **Settings:** users, web interface and API tokens |
|
||||||
|
|  |  |
|
||||||
|
| **My account:** profile, password and your app tokens | **Sign-in** |
|
||||||
|
|
||||||
|
The screenshots show sample data from the built-in simulator.
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
- **Client private keys are never stored.** A config is shown once, as a
|
- **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
|
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
|
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
|
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
|
GET. Full-access tokens can do everything the web interface does except backup
|
||||||
endpoints marked "signed in": users, passwords, API tokens, backup and restore.
|
and restore. Users, passwords and API tokens need a full-access token even for
|
||||||
|
reading.
|
||||||
|
|
||||||
`POST /users` and `POST /users/{id}/reset-password` take
|
`POST /users` and `POST /users/{id}/reset-password` take
|
||||||
`{"password": "…", "mustChangePassword": true}`; with `true` (the default) the
|
`{"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 /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup
|
||||||
GET /settings PATCH /settings POST /restart
|
GET /settings PATCH /settings POST /restart
|
||||||
GET /logs?level=&limit=&audit=1 GET /logs/download
|
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)
|
public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// applyResult saves-then-applies: the config is already stored, so a kernel
|
||||||
// error is reported but does not undo the change.
|
// error is reported but does not undo the change.
|
||||||
func (a *App) apply() string {
|
func (a *App) apply() string {
|
||||||
@@ -105,16 +116,18 @@ func (a *App) routes() http.Handler {
|
|||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
g := func(pattern string, h http.HandlerFunc) { mux.HandleFunc(pattern, a.guard(false, h)) }
|
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)) }
|
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/login", a.login)
|
||||||
mux.HandleFunc("POST /api/v1/auth/logout", a.logout)
|
mux.HandleFunc("POST /api/v1/auth/logout", a.logout)
|
||||||
g("GET /api/v1/auth/me", a.me)
|
g("GET /api/v1/auth/me", a.me)
|
||||||
adm("POST /api/v1/auth/password", a.changePassword)
|
full("POST /api/v1/auth/password", a.changePassword)
|
||||||
adm("GET /api/v1/users", a.listUsers)
|
full("GET /api/v1/users", a.listUsers)
|
||||||
adm("POST /api/v1/users", a.createUser)
|
full("POST /api/v1/users", a.createUser)
|
||||||
adm("PATCH /api/v1/users/{id}", a.patchUser)
|
full("PATCH /api/v1/users/{id}", a.patchUser)
|
||||||
adm("POST /api/v1/users/{id}/reset-password", a.resetPassword)
|
full("POST /api/v1/users/{id}/reset-password", a.resetPassword)
|
||||||
adm("DELETE /api/v1/users/{id}", a.deleteUser)
|
full("DELETE /api/v1/users/{id}", a.deleteUser)
|
||||||
|
|
||||||
g("GET /api/v1/status", a.status)
|
g("GET /api/v1/status", a.status)
|
||||||
g("GET /api/v1/stats", a.allStats)
|
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("GET /api/v1/setup/{token}", a.setupInfo)
|
||||||
mux.HandleFunc("POST /api/v1/setup/{token}", a.setupRedeem)
|
mux.HandleFunc("POST /api/v1/setup/{token}", a.setupRedeem)
|
||||||
|
|
||||||
// Full-access tokens (the iOS app) may change app settings and read logs.
|
// Full-access tokens (the iOS app) may change app settings, read logs and
|
||||||
// Users, passwords, tokens and backups need a signed-in user.
|
// manage users and tokens. Backups need a signed-in user.
|
||||||
g("GET /api/v1/settings", a.getSettings)
|
g("GET /api/v1/settings", a.getSettings)
|
||||||
g("PATCH /api/v1/settings", a.patchSettings)
|
g("PATCH /api/v1/settings", a.patchSettings)
|
||||||
g("POST /api/v1/restart", a.restart)
|
g("POST /api/v1/restart", a.restart)
|
||||||
adm("GET /api/v1/tokens", a.listTokens)
|
full("GET /api/v1/tokens", a.listTokens)
|
||||||
adm("POST /api/v1/tokens", a.createToken)
|
full("POST /api/v1/tokens", a.createToken)
|
||||||
adm("DELETE /api/v1/tokens/{id}", a.deleteToken)
|
full("DELETE /api/v1/tokens/{id}", a.deleteToken)
|
||||||
g("GET /api/v1/logs", a.logs)
|
g("GET /api/v1/logs", a.logs)
|
||||||
g("GET /api/v1/logs/download", a.downloadLog)
|
g("GET /api/v1/logs/download", a.downloadLog)
|
||||||
adm("GET /api/v1/backup", a.backup)
|
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) {
|
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))
|
||||||
@@ -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,
|
"id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope,
|
||||||
"mustChangePassword": p.MustChangePassword, "version": version, "session": p.Session,
|
"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 {
|
if _, u := a.store.Get().userByID(p.UserID); u != nil {
|
||||||
out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created
|
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,
|
"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 +1009,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 {
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -56,7 +56,12 @@ a.brand, a.brand:hover { color: #fff; text-decoration: none; }
|
|||||||
.side a.nav.on { background: #2a2b31; color: #fff; }
|
.side a.nav.on { background: #2a2b31; color: #fff; }
|
||||||
.side .count { margin-left: auto; font-size: 12px; color: #8d8e93; }
|
.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 { 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 { 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:hover { background: #222328; color: #fff; }
|
||||||
.side .acct.on { background: #2a2b31; 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 .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; }
|
.side .footrow { display: flex; justify-content: space-between; padding: 10px 12px 0; }
|
||||||
.main { flex: 999 1 560px; min-width: 0; padding: 32px 32px 56px; }
|
.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; }
|
.wrap { max-width: 1120px; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; }
|
||||||
@media (max-width: 640px) { .main { padding: 20px 16px 40px; } }
|
@media (max-width: 640px) { .main { padding: 20px 16px 40px; } }
|
||||||
|
|
||||||
|
|||||||
@@ -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"/>',
|
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"/>',
|
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"/>',
|
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.
|
// The Hannya mark: the horned demon mask of Noh. Same drawing as favicon.svg.
|
||||||
@@ -513,12 +514,13 @@
|
|||||||
srvBox,
|
srvBox,
|
||||||
NAV.map(([href, ic, label]) => (navLinks[href] = h('a', { class: 'nav', href }, icon(ic), label, ic === 'peers' ? peerCount : null))),
|
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: 'foot' },
|
||||||
(navLinks['#/account'] = h('a', { class: 'acct', href: '#/account' },
|
h('div', { class: 'acctrow' },
|
||||||
h('span', { class: 'avatar', 'aria-hidden': 'true' }, me.name.slice(0, 1).toUpperCase()),
|
(navLinks['#/account'] = h('a', { class: 'acct', href: '#/account' },
|
||||||
h('span', null, h('strong', null, me.name), h('span', null, 'My 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('div', { class: 'footrow' },
|
||||||
h('button', { type: 'button', onClick: logout }, 'Sign out'),
|
h('span', null, 'v' + me.version.replace(/^v/, '')))));
|
||||||
h('span', null, 'v' + me.version))));
|
|
||||||
main = h('main', { class: 'main', id: 'main' });
|
main = h('main', { class: 'main', id: 'main' });
|
||||||
app.replaceChildren(h('div', { class: 'shell' }, nav, main));
|
app.replaceChildren(h('div', { class: 'shell' }, nav, main));
|
||||||
refreshSide();
|
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); }
|
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 +1642,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.')),
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
`
|
||||||
@@ -328,6 +328,12 @@ func TestAPI(t *testing.T) {
|
|||||||
bearer("GET", "/tokens", 403)
|
bearer("GET", "/tokens", 403)
|
||||||
bearer("GET", "/peers/"+id+"/setup", 403) // the link would set up a device
|
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)
|
call("DELETE", "/peers/"+id, nil, 200)
|
||||||
if len(store.Get().Peers) != 0 {
|
if len(store.Get().Peers) != 0 {
|
||||||
t.Fatal("peer not deleted")
|
t.Fatal("peer not deleted")
|
||||||
@@ -877,3 +883,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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 81 KiB |
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||