Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e8dba6072 | |||
| aa4ca20296 | |||
| 1bfede250c | |||
| 8edde9f5e7 |
@@ -3,15 +3,18 @@ package main
|
|||||||
import (
|
import (
|
||||||
"cmp"
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,7 +32,8 @@ type App struct {
|
|||||||
geo *Geo // nil in tests
|
geo *Geo // nil in tests
|
||||||
updates *Updater // nil in tests
|
updates *Updater // nil in tests
|
||||||
started time.Time
|
started time.Time
|
||||||
shutdown func() // graceful stop; systemd restarts the service
|
shutdown func() // graceful stop; systemd restarts the service
|
||||||
|
webAddrs []string // the addresses the web server listens on now
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- helpers ---
|
// --- helpers ---
|
||||||
@@ -353,7 +357,7 @@ func (a *App) status(w http.ResponseWriter, r *http.Request) {
|
|||||||
d30, u30 := sumPoints(a.stats.series(nil, "30d"))
|
d30, u30 := sumPoints(a.stats.series(nil, "30d"))
|
||||||
checks := a.kernel.Checks(cfg)
|
checks := a.kernel.Checks(cfg)
|
||||||
last, applyErr := a.recon.Status()
|
last, applyErr := a.recon.Status()
|
||||||
ac := Check{Name: "Last apply", OK: applyErr == nil, Detail: "applied " + last.Format(time.RFC3339)}
|
ac := Check{Name: "Kernel in sync", OK: applyErr == nil, Detail: "applied " + last.Format(time.RFC3339)}
|
||||||
if applyErr != nil {
|
if applyErr != nil {
|
||||||
ac.Detail = applyErr.Error()
|
ac.Detail = applyErr.Error()
|
||||||
}
|
}
|
||||||
@@ -1090,11 +1094,15 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
b, _ := json.Marshal(w)
|
b, _ := json.Marshal(w)
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
before := listen()
|
before, oldWeb := listen(), c.Web
|
||||||
if err := field(m, "web", &c.Web); err != nil {
|
if err := field(m, "web", &c.Web); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
restart = listen() != before
|
if restart = listen() != before; restart {
|
||||||
|
if err := a.checkWebStart(oldWeb, c.Web); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := field(m, "stats", &c.Stats); err != nil {
|
if err := field(m, "stats", &c.Stats); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -1236,7 +1244,20 @@ func (a *App) restore(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, badRequest("this file has no server key; is it a backup of this app?"))
|
writeErr(w, badRequest("this file has no server key; is it a backup of this app?"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := a.store.Update(func(c *Config) error { *c = in; return nil }); err != nil {
|
if in.Version > configVersion {
|
||||||
|
writeErr(w, badRequest("this backup is from a newer version of %s; update this server first", appName))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.applyDefaults()
|
||||||
|
if !in.passwordSet() {
|
||||||
|
writeErr(w, badRequest("this backup has no user with a password; restoring it would lock everyone out"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.store.Update(func(c *Config) error {
|
||||||
|
old := c.Web
|
||||||
|
*c = in
|
||||||
|
return a.checkWebStart(old, c.Web)
|
||||||
|
}); err != nil {
|
||||||
writeErr(w, err)
|
writeErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1244,6 +1265,55 @@ func (a *App) restore(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "applyError": a.apply(), "restartRequired": true})
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "applyError": a.apply(), "restartRequired": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkWebStart refuses web settings the service could not start with: an
|
||||||
|
// address it cannot listen on, or certificate files it cannot read. The
|
||||||
|
// service would stop at the next restart, and the web interface and the API
|
||||||
|
// with it.
|
||||||
|
func (a *App) checkWebStart(old, next WebConfig) error {
|
||||||
|
if err := validateListen(next.Listen, "listen address", false); err != nil {
|
||||||
|
return &userError{err.Error()}
|
||||||
|
}
|
||||||
|
if err := validateListen(next.HTTPListen, "HTTP listen address", true); err != nil {
|
||||||
|
return &userError{err.Error()}
|
||||||
|
}
|
||||||
|
if next.TLS.Mode == "files" && next.TLS != old.TLS {
|
||||||
|
if _, err := tls.LoadX509KeyPair(next.TLS.CertFile, next.TLS.KeyFile); err != nil {
|
||||||
|
return badRequest("the certificate files cannot be used: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addrs := []string{next.Listen}
|
||||||
|
if next.HTTPListen != "" && next.TLS.Mode != "off" {
|
||||||
|
addrs = append(addrs, next.HTTPListen)
|
||||||
|
}
|
||||||
|
for _, addr := range addrs {
|
||||||
|
if err := a.canListen(addr); err != nil {
|
||||||
|
return badRequest("cannot listen on %s: %v", addr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// canListen tries to listen on addr. An address the service listens on now,
|
||||||
|
// or one whose port it holds, is fine: it is free again after the restart.
|
||||||
|
func (a *App) canListen(addr string) error {
|
||||||
|
if slices.Contains(a.webAddrs, addr) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ln, err := net.Listen("tcp", addr)
|
||||||
|
if err == nil {
|
||||||
|
return ln.Close()
|
||||||
|
}
|
||||||
|
if errors.Is(err, syscall.EADDRINUSE) {
|
||||||
|
_, port, _ := net.SplitHostPort(addr)
|
||||||
|
for _, own := range a.webAddrs {
|
||||||
|
if _, p, _ := net.SplitHostPort(own); p == port {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// applyRuntime applies the settings that take effect without a restart: log
|
// applyRuntime applies the settings that take effect without a restart: log
|
||||||
// level and log rotation. Traffic retention is read by the stats sampler.
|
// level and log rotation. Traffic retention is read by the stats sampler.
|
||||||
func (a *App) applyRuntime(c *Config) {
|
func (a *App) applyRuntime(c *Config) {
|
||||||
|
|||||||
@@ -33,8 +33,20 @@
|
|||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
html, body { margin: 0; }
|
html, body { margin: 0; }
|
||||||
body { background: var(--ground); color: var(--ink); font-family: var(--sans); font-size: 14px; line-height: 1.45; }
|
body { background: var(--ground); color: var(--ink); font-family: var(--sans); font-size: 14px; line-height: 1.45; }
|
||||||
a { color: var(--link); }
|
a { color: var(--link); text-decoration-color: rgba(28, 92, 171, .35); text-underline-offset: 3px; }
|
||||||
a:hover { color: var(--link-hover); }
|
a:hover { color: var(--link-hover); text-decoration-color: currentColor; }
|
||||||
|
/* Links to another page (go, back) are ink with an arrow that nudges on
|
||||||
|
hover; outside links (ext) keep the link colour and get ↗. */
|
||||||
|
a.go, a.back { color: var(--ink); font-size: 13px; font-weight: 500; text-decoration: none; white-space: nowrap; }
|
||||||
|
a.go:hover, a.back:hover { color: var(--link); }
|
||||||
|
a.go .ar { margin-left: 4px; }
|
||||||
|
a.back .ar { margin-right: 4px; }
|
||||||
|
a.ext .ar { margin-left: 2px; font-size: .8em; }
|
||||||
|
.ar { display: inline-block; transition: transform .15s; }
|
||||||
|
a.go:hover .ar { transform: translateX(3px); }
|
||||||
|
a.back:hover .ar { transform: translateX(-3px); }
|
||||||
|
a.ext:hover .ar { transform: translate(2px, -2px); }
|
||||||
|
@media (prefers-reduced-motion: reduce) { .ar { transition: none; } }
|
||||||
:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||||
[hidden] { display: none !important; }
|
[hidden] { display: none !important; }
|
||||||
.mono { font-family: var(--mono); font-size: 13px; }
|
.mono { font-family: var(--mono); font-size: 13px; }
|
||||||
@@ -86,7 +98,7 @@ a.brand, a.brand:hover { color: #fff; text-decoration: none; }
|
|||||||
.titleline { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
|
.titleline { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
|
||||||
h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; overflow-wrap: anywhere; }
|
h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; overflow-wrap: anywhere; }
|
||||||
.sub { margin: 4px 0 0; color: var(--ink-2); }
|
.sub { margin: 4px 0 0; color: var(--ink-2); }
|
||||||
.back { font-size: 13px; margin-bottom: -8px; }
|
.back { margin-bottom: -8px; align-self: flex-start; }
|
||||||
|
|
||||||
/* cards */
|
/* cards */
|
||||||
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 20px; min-width: 0; }
|
.card { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 20px; min-width: 0; }
|
||||||
@@ -151,7 +163,7 @@ tr:last-child td { border-bottom: 0; }
|
|||||||
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
td .note { font-size: 12px; color: var(--ink-3); }
|
td .note { font-size: 12px; color: var(--ink-3); }
|
||||||
a.pname { color: var(--ink); font-weight: 600; text-decoration: none; }
|
a.pname { color: var(--ink); font-weight: 600; text-decoration: none; }
|
||||||
a.pname:hover, a.pname:focus-visible { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; }
|
a.pname:hover { color: var(--link); }
|
||||||
.empty { padding: 24px 12px; margin: 0; text-align: center; color: var(--ink-3); }
|
.empty { padding: 24px 12px; margin: 0; text-align: center; color: var(--ink-3); }
|
||||||
|
|
||||||
/* forms */
|
/* forms */
|
||||||
@@ -334,8 +346,8 @@ dialog::backdrop { background: rgba(22, 23, 26, .55); }
|
|||||||
.setupbox { width: 100%; max-width: 400px; display: flex; flex-direction: column; gap: 24px; margin: auto 0; }
|
.setupbox { width: 100%; max-width: 400px; display: flex; flex-direction: column; gap: 24px; margin: auto 0; }
|
||||||
.setupbox h1 { font-size: 22px; }
|
.setupbox h1 { font-size: 22px; }
|
||||||
.setupbox p { margin: 0; color: #c9c9c3; }
|
.setupbox p { margin: 0; color: #c9c9c3; }
|
||||||
.setupbox a { color: #9cc3f5; }
|
.setupbox a { color: #9cc3f5; text-decoration-color: rgba(156, 195, 245, .4); }
|
||||||
.setupbox a:hover { color: #fff; }
|
.setupbox a:hover { color: #fff; text-decoration-color: currentColor; }
|
||||||
.setupbox .center, .setupbox.center { text-align: center; display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
.setupbox .center, .setupbox.center { text-align: center; display: flex; flex-direction: column; align-items: center; gap: 8px; }
|
||||||
.setupbox.center { gap: 20px; }
|
.setupbox.center { gap: 20px; }
|
||||||
.setupbox .ghost { opacity: .45; }
|
.setupbox .ghost { opacity: .45; }
|
||||||
|
|||||||
@@ -169,6 +169,14 @@
|
|||||||
|
|
||||||
const badge = (st) => h('span', { class: 'badge' }, h('span', { class: st.dot }), st.label);
|
const badge = (st) => h('span', { class: 'badge' }, h('span', { class: st.dot }), st.label);
|
||||||
|
|
||||||
|
// go links to another page of the app; back returns to one. Their arrows
|
||||||
|
// nudge on hover. ext opens an outside page in a new tab, marked with ↗.
|
||||||
|
const arrow = (c) => h('span', { class: 'ar', 'aria-hidden': 'true' }, c);
|
||||||
|
const go = (href, text) => h('a', { class: 'go', href }, text, arrow('→'));
|
||||||
|
const back = (href, text) => h('a', { class: 'back', href }, arrow('←'), text);
|
||||||
|
const ext = (href, text) => h('a', { class: 'ext', href, target: '_blank', rel: 'noopener' }, text, arrow('↗'), h('span', { class: 'sr' }, ' (opens in a new tab)'));
|
||||||
|
const peerLink = (p) => h('a', { class: 'pname', href: '#/peers/' + p.id }, p.name);
|
||||||
|
|
||||||
// svg builds an SVG element; attrs are set as attributes.
|
// svg builds an SVG element; attrs are set as attributes.
|
||||||
function svg(tag, attrs, ...kids) {
|
function svg(tag, attrs, ...kids) {
|
||||||
const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
||||||
@@ -1086,17 +1094,17 @@
|
|||||||
|
|
||||||
h('div', { class: 'cols' },
|
h('div', { class: 'cols' },
|
||||||
h('section', { class: 'card flush' },
|
h('section', { class: 'card flush' },
|
||||||
h('div', { class: 'cardhead' }, h('h2', null, 'Peers'), h('a', { href: '#/peers' }, 'All peers')),
|
h('div', { class: 'cardhead' }, h('h2', null, 'Peers'), go('#/peers', 'All peers')),
|
||||||
top.length ? h('div', { class: 'tbl' }, h('table', { class: 'narrow' },
|
top.length ? h('div', { class: 'tbl' }, h('table', { class: 'narrow' },
|
||||||
h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Status'), h('th', { class: 'num' }, 'Download, 24 h'), h('th', { class: 'num' }, 'Upload, 24 h'))),
|
h('thead', null, h('tr', null, h('th', null, 'Name'), h('th', null, 'Status'), h('th', { class: 'num' }, 'Download, 24 h'), h('th', { class: 'num' }, 'Upload, 24 h'))),
|
||||||
h('tbody', null, top.map((p) => h('tr', null,
|
h('tbody', null, top.map((p) => h('tr', null,
|
||||||
h('td', null, h('a', { href: '#/peers/' + p.id }, p.name)),
|
h('td', null, peerLink(p)),
|
||||||
h('td', null, badge(peerState(p))),
|
h('td', null, badge(peerState(p))),
|
||||||
h('td', { class: 'num' }, fmtBytes(p.stats.down24h)),
|
h('td', { class: 'num' }, fmtBytes(p.stats.down24h)),
|
||||||
h('td', { class: 'num' }, fmtBytes(p.stats.up24h)))))))
|
h('td', { class: 'num' }, fmtBytes(p.stats.up24h)))))))
|
||||||
: h('p', { class: 'empty' }, 'No peers yet. ', h('a', { href: '#/peers/new' }, 'Add the first one'))),
|
: h('p', { class: 'empty' }, 'No peers yet. ', go('#/peers/new', 'Add the first one'))),
|
||||||
logs ? h('section', { class: 'card' },
|
logs ? h('section', { class: 'card' },
|
||||||
h('div', { class: 'cardhead' }, h('h2', null, 'Recent activity'), h('a', { href: '#/log' }, 'Log')),
|
h('div', { class: 'cardhead' }, h('h2', null, 'Recent activity'), go('#/log', 'Log')),
|
||||||
logs.lines.length
|
logs.lines.length
|
||||||
? h('div', null, logs.lines.map((l) => h('div', { class: 'ev' }, h('time', { datetime: l.time }, fmtWhen(l.time)), h('span', null, describeAudit(l)))))
|
? h('div', null, logs.lines.map((l) => h('div', { class: 'ev' }, h('time', { datetime: l.time }, fmtWhen(l.time)), h('span', null, describeAudit(l)))))
|
||||||
: h('p', { class: 'empty' }, 'No changes yet.')) : null));
|
: h('p', { class: 'empty' }, 'No changes yet.')) : null));
|
||||||
@@ -1294,7 +1302,7 @@
|
|||||||
h('tbody', null, online.map(({ p, r, hist }) => {
|
h('tbody', null, online.map(({ p, r, hist }) => {
|
||||||
const idle = r[0] + r[1] < IDLE_BPS;
|
const idle = r[0] + r[1] < IDLE_BPS;
|
||||||
return h('tr', { class: idle ? 'idle' : null },
|
return h('tr', { class: idle ? 'idle' : null },
|
||||||
h('td', null, h('a', { href: '#/peers/' + p.id }, p.name), idle ? h('span', { class: 'tag plain' }, 'idle') : null),
|
h('td', null, peerLink(p), idle ? h('span', { class: 'tag plain' }, 'idle') : null),
|
||||||
h('td', null, rateSpark(hist)),
|
h('td', null, rateSpark(hist)),
|
||||||
h('td', { class: 'num' }, rate(r[0], idle)),
|
h('td', { class: 'num' }, rate(r[0], idle)),
|
||||||
h('td', { class: 'num' }, rate(r[1], idle)),
|
h('td', { class: 'num' }, rate(r[1], idle)),
|
||||||
@@ -1424,7 +1432,7 @@
|
|||||||
return hit && keep;
|
return hit && keep;
|
||||||
});
|
});
|
||||||
tbody.replaceChildren(...sorted(rows).map((p) => h('tr', null,
|
tbody.replaceChildren(...sorted(rows).map((p) => h('tr', null,
|
||||||
h('td', null, h('a', { class: 'pname', href: '#/peers/' + p.id }, p.name), p.note ? h('div', { class: 'note' }, p.note) : null),
|
h('td', null, peerLink(p), p.note ? h('div', { class: 'note' }, p.note) : null),
|
||||||
h('td', { class: 'mono' }, p.ipv4),
|
h('td', { class: 'mono' }, p.ipv4),
|
||||||
h('td', null, badge(peerState(p))),
|
h('td', null, badge(peerState(p))),
|
||||||
h('td', { class: 'mono muted' }, p.stats.endpoint || '–',
|
h('td', { class: 'mono muted' }, p.stats.endpoint || '–',
|
||||||
@@ -1579,7 +1587,7 @@
|
|||||||
drawPreview();
|
drawPreview();
|
||||||
|
|
||||||
fill(wrap,
|
fill(wrap,
|
||||||
h('a', { class: 'back', href: '#/peers' }, '← Peers'),
|
back('#/peers', 'Peers'),
|
||||||
h('div', null, h('h1', null, 'Add peer'), h('p', { class: 'sub' }, 'Assigns the next free address and adds the peer to ' + srv.interface + ' without a restart.')),
|
h('div', null, h('h1', null, 'Add peer'), h('p', { class: 'sub' }, 'Assigns the next free address and adds the peer to ' + srv.interface + ' without a restart.')),
|
||||||
h('div', { class: 'split' }, form,
|
h('div', { class: 'split' }, form,
|
||||||
h('aside', { class: 'card aside', 'aria-labelledby': 'pv' },
|
h('aside', { class: 'card aside', 'aria-labelledby': 'pv' },
|
||||||
@@ -1736,7 +1744,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
fill(wrap,
|
fill(wrap,
|
||||||
h('a', { class: 'back', href: '#/peers' }, '← Peers'),
|
back('#/peers', 'Peers'),
|
||||||
h('div', { class: 'head' },
|
h('div', { class: 'head' },
|
||||||
h('div', null,
|
h('div', null,
|
||||||
h('div', { class: 'titleline' }, h('h1', null, p.name), badge(st.key === 'online' ? { ...st, label: 'Online · handshake ' + ago(p.stats.lastHandshake) } : st)),
|
h('div', { class: 'titleline' }, h('h1', null, p.name), badge(st.key === 'online' ? { ...st, label: 'Online · handshake ' + ago(p.stats.lastHandshake) } : st)),
|
||||||
@@ -1787,7 +1795,7 @@
|
|||||||
: h('p', { class: 'empty' }, 'No connections recorded yet.'),
|
: h('p', { class: 'empty' }, 'No connections recorded yet.'),
|
||||||
sessions.length > SHORT ? h('div', { style: { margin: '8px 12px 0' } }, sessMore) : null,
|
sessions.length > SHORT ? h('div', { style: { margin: '8px 12px 0' } }, sessMore) : null,
|
||||||
h('p', { class: 'hint', style: { margin: '4px 12px 12px' } }, 'Country and network: ',
|
h('p', { class: 'hint', style: { margin: '4px 12px 12px' } }, 'Country and network: ',
|
||||||
h('a', { href: 'https://db-ip.com', target: '_blank', rel: 'noopener' }, 'IP Geolocation by DB-IP'),
|
ext('https://db-ip.com', 'IP Geolocation by DB-IP'),
|
||||||
'. Kept as long as the daily traffic history.')),
|
'. Kept as long as the daily traffic history.')),
|
||||||
|
|
||||||
h('form', { class: 'card', onSubmit: save },
|
h('form', { class: 'card', onSubmit: save },
|
||||||
@@ -1847,8 +1855,8 @@
|
|||||||
t.status = c.ok ? 'Present' : 'Missing';
|
t.status = c.ok ? 'Present' : 'Missing';
|
||||||
if (/^table /.test(c.detail)) { t.raw = c.detail.replace(/ (present|missing)$/, ''); t.problem = null; }
|
if (/^table /.test(c.detail)) { t.raw = c.detail.replace(/ (present|missing)$/, ''); t.problem = null; }
|
||||||
break;
|
break;
|
||||||
case 'Last apply':
|
case 'Kernel in sync':
|
||||||
if (c.ok) { const iso = c.detail.replace(/^applied /, ''); t.status = ago(iso); t.title = fmtStamp(iso); } else t.status = 'Failed';
|
if (c.ok) { const iso = c.detail.replace(/^applied /, ''); t.status = ago(iso); t.title = fmtStamp(iso); } else t.status = 'Out of sync';
|
||||||
break;
|
break;
|
||||||
case 'Latency check': t.status = c.ok ? 'Tunnel ping works' : 'Failing'; break;
|
case 'Latency check': t.status = c.ok ? 'Tunnel ping works' : 'Failing'; break;
|
||||||
}
|
}
|
||||||
@@ -1980,7 +1988,7 @@
|
|||||||
fieldEl('up6', 'IPv6 uplink interface', h('input', { id: 'up6', class: 'mono', value: draft.uplinkV6, placeholder: 'auto: ' + (srv.detectedUplinkV6 || 'none found'), onInput: str('uplinkV6') })),
|
fieldEl('up6', 'IPv6 uplink interface', h('input', { id: 'up6', class: 'mono', value: draft.uplinkV6, placeholder: 'auto: ' + (srv.detectedUplinkV6 || 'none found'), onInput: str('uplinkV6') })),
|
||||||
cb('nat', 'Masquerade (NAT) peer traffic to the internet'),
|
cb('nat', 'Masquerade (NAT) peer traffic to the internet'),
|
||||||
cb('peerToPeer', 'Allow peers to reach each other'),
|
cb('peerToPeer', 'Allow peers to reach each other'),
|
||||||
cb('lanAccess', 'Allow peers to reach the server\'s LAN', 'Private networks on the uplink interface'),
|
cb('lanAccess', 'Allow peers to reach the server\'s LAN', 'Private IPv4 and the IPv6 networks on the uplink interface'),
|
||||||
cb('openPort', 'Accept UDP ' + draft.listenPort + ' in the input chain'))),
|
cb('openPort', 'Accept UDP ' + draft.listenPort + ' in the input chain'))),
|
||||||
|
|
||||||
h('section', { class: 'card', 'aria-labelledby': 'ky' },
|
h('section', { class: 'card', 'aria-labelledby': 'ky' },
|
||||||
@@ -2117,7 +2125,7 @@
|
|||||||
notes ? h('div', { class: 'upnotes' },
|
notes ? h('div', { class: 'upnotes' },
|
||||||
h('div', { class: 'hd' }, h('strong', null, 'What\'s new in ' + rel.version),
|
h('div', { class: 'hd' }, h('strong', null, 'What\'s new in ' + rel.version),
|
||||||
h('span', { class: 'muted' }, 'Released ' + fmtDate(rel.published) + ' · from ' + srcName()),
|
h('span', { class: 'muted' }, 'Released ' + fmtDate(rel.published) + ' · from ' + srcName()),
|
||||||
h('a', { href: rel.url, target: '_blank', rel: 'noopener' }, 'Full notes on ' + srcName())),
|
ext(rel.url, 'Full notes on ' + srcName())),
|
||||||
/security/i.test(notes.summary) ? h('p', { class: 'notice' }, 'Includes security fixes.') : null,
|
/security/i.test(notes.summary) ? h('p', { class: 'notice' }, 'Includes security fixes.') : null,
|
||||||
notes.summary ? h('p', null, mdInline(notes.summary)) : null,
|
notes.summary ? h('p', null, mdInline(notes.summary)) : null,
|
||||||
notes.items.length ? h('ul', null, notes.items.map((t) => h('li', null, mdInline(t)))) : null) : null,
|
notes.items.length ? h('ul', null, notes.items.map((t) => h('li', null, mdInline(t)))) : null) : null,
|
||||||
@@ -2125,7 +2133,7 @@
|
|||||||
h('div', { class: 'hd' }, h('strong', null, 'Update this server'), h('span', { class: 'muted' }, 'Run on the server. VPN connections stay up.')),
|
h('div', { class: 'hd' }, h('strong', null, 'Update this server'), h('span', { class: 'muted' }, 'Run on the server. VPN connections stay up.')),
|
||||||
h('pre', { class: 'code' }, cmds),
|
h('pre', { class: 'code' }, cmds),
|
||||||
h('div', null, h('button', { type: 'button', class: 'btn small', onClick: () => copy(cmds) }, 'Copy commands'))) : null,
|
h('div', null, h('button', { type: 'button', class: 'btn small', onClick: () => copy(cmds) }, 'Copy commands'))) : null,
|
||||||
st.available && !st.file ? h('p', null, 'No release file is built for this platform. ', h('a', { href: rel.url, target: '_blank', rel: 'noopener' }, 'See the release')) : null,
|
st.available && !st.file ? h('p', null, 'No release file is built for this platform. ', ext(rel.url, 'See the release')) : null,
|
||||||
h('fieldset', { class: 'section' }, h('legend', { class: 'legend' }, 'Release source'),
|
h('fieldset', { class: 'section' }, h('legend', { class: 'legend' }, 'Release source'),
|
||||||
h('div', { class: 'grid' }, SOURCES.map(([k, name, where]) => h('label', { class: 'opt' },
|
h('div', { class: 'grid' }, SOURCES.map(([k, name, where]) => h('label', { class: 'opt' },
|
||||||
h('input', { type: 'radio', name: 'upsrc', value: k, checked: st.source === k, onChange: () => save({ source: k }) }),
|
h('input', { type: 'radio', name: 'upsrc', value: k, checked: st.source === k, onChange: () => save({ source: k }) }),
|
||||||
|
|||||||
@@ -287,9 +287,14 @@ func remoteIP(r *http.Request) string {
|
|||||||
host = r.RemoteAddr
|
host = r.RemoteAddr
|
||||||
}
|
}
|
||||||
// Behind a local reverse proxy the real client is in X-Forwarded-For.
|
// Behind a local reverse proxy the real client is in X-Forwarded-For.
|
||||||
|
// The proxy appends the address it saw, so only the last entry counts:
|
||||||
|
// earlier ones come from the client and can be anything.
|
||||||
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
|
if ip := net.ParseIP(host); ip != nil && ip.IsLoopback() {
|
||||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
if xff := r.Header.Values("X-Forwarded-For"); len(xff) > 0 {
|
||||||
return strings.TrimSpace(strings.Split(xff, ",")[0])
|
list := strings.Split(xff[len(xff)-1], ",")
|
||||||
|
if last := strings.TrimSpace(list[len(list)-1]); net.ParseIP(last) != nil {
|
||||||
|
return last
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return host
|
return host
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -76,8 +77,29 @@ const (
|
|||||||
minLogFiles, maxLogFiles = 1, 100
|
minLogFiles, maxLogFiles = 1, 100
|
||||||
minHourlyHours, maxHourlyHrs = 24, 24 * 31
|
minHourlyHours, maxHourlyHrs = 24, 24 * 31
|
||||||
minDailyDays, maxDailyDays = 7, 3660
|
minDailyDays, maxDailyDays = 7, 3660
|
||||||
|
minSessionHours = 1
|
||||||
|
maxSessionHours = 30 * 24
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// validateListen checks a listen address like ":443" or "192.0.2.1:443".
|
||||||
|
// Empty is allowed when optional (the HTTP listener is then off).
|
||||||
|
func validateListen(addr, field string, optional bool) error {
|
||||||
|
if addr == "" && optional {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
host, port, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s %q must look like :443 or 192.0.2.1:443", field, addr)
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
|
||||||
|
return fmt.Errorf("%s %q: the port must be 1–65535", field, addr)
|
||||||
|
}
|
||||||
|
if host != "" && host != "localhost" && checkEndpoint(host) != nil {
|
||||||
|
return fmt.Errorf("%s %q: %q is not an IP address or host name", field, addr, host)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type WebConfig struct {
|
type WebConfig struct {
|
||||||
Listen string `json:"listen"` // HTTPS (or HTTP when tls.mode is "off") listen address
|
Listen string `json:"listen"` // HTTPS (or HTTP when tls.mode is "off") listen address
|
||||||
HTTPListen string `json:"httpListen"` // plain HTTP for ACME http-01 and redirects; "" disables
|
HTTPListen string `json:"httpListen"` // plain HTTP for ACME http-01 and redirects; "" disables
|
||||||
@@ -364,7 +386,9 @@ func (c *Config) validate() error {
|
|||||||
if v6.Masked() != v6 {
|
if v6.Masked() != v6 {
|
||||||
return fmt.Errorf("IPv6 network must be the network address, e.g. %s", v6.Masked())
|
return fmt.Errorf("IPv6 network must be the network address, e.g. %s", v6.Masked())
|
||||||
}
|
}
|
||||||
if s.Endpoint != "" && strings.ContainsAny(s.Endpoint, " /:") && net.ParseIP(s.Endpoint) == nil {
|
// The endpoint is written into client configs as is, so it must be a
|
||||||
|
// plain host name or IP: anything else could add lines to them.
|
||||||
|
if s.Endpoint != "" && checkEndpoint(s.Endpoint) != nil {
|
||||||
return errors.New("endpoint must be a host name or IP address without port")
|
return errors.New("endpoint must be a host name or IP address without port")
|
||||||
}
|
}
|
||||||
if err := validateHostList(s.ClientDefaults.DNS, "DNS", false); err != nil {
|
if err := validateHostList(s.ClientDefaults.DNS, "DNS", false); err != nil {
|
||||||
@@ -397,6 +421,15 @@ func (c *Config) validate() error {
|
|||||||
if _, ok := updateSources[c.Updates.Source]; !ok {
|
if _, ok := updateSources[c.Updates.Source]; !ok {
|
||||||
return fmt.Errorf("update source must be gitea or github")
|
return fmt.Errorf("update source must be gitea or github")
|
||||||
}
|
}
|
||||||
|
if err := validateListen(c.Web.Listen, "listen address", false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := validateListen(c.Web.HTTPListen, "HTTP listen address", true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if h := c.Web.SessionHours; h < minSessionHours || h > maxSessionHours {
|
||||||
|
return fmt.Errorf("session length must be %d–%d hours", minSessionHours, maxSessionHours)
|
||||||
|
}
|
||||||
switch c.Web.TLS.Mode {
|
switch c.Web.TLS.Mode {
|
||||||
case "acme":
|
case "acme":
|
||||||
if c.Web.TLS.Domain == "" {
|
if c.Web.TLS.Domain == "" {
|
||||||
@@ -586,6 +619,12 @@ func (s *Store) Update(fn func(c *Config) error) error {
|
|||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return &userError{err.Error()}
|
return &userError{err.Error()}
|
||||||
}
|
}
|
||||||
|
// With no user left (applyDefaults then adds an "admin" without a
|
||||||
|
// password), nobody could sign in until someone ran "passwd" on the server.
|
||||||
|
if old.passwordSet() && !next.passwordSet() {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return &userError{"this would leave no user with a password, and nobody could sign in"}
|
||||||
|
}
|
||||||
if err := writeFileAtomic(s.path, next, 0o600); err != nil {
|
if err := writeFileAtomic(s.path, next, 0o600); err != nil {
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -40,6 +41,28 @@ type Kernel interface {
|
|||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// lanBlock picks, from the networks on the uplinks, the ones peers must not
|
||||||
|
// reach while LAN access is off: private IPv4 networks, and IPv6 networks
|
||||||
|
// except link-local, since a home LAN uses global IPv6 addresses. IPv6
|
||||||
|
// prefixes shorter than /48 are left out: they are no LAN.
|
||||||
|
func lanBlock(nets []netip.Prefix) []netip.Prefix {
|
||||||
|
var out []netip.Prefix
|
||||||
|
for _, p := range nets {
|
||||||
|
a := p.Addr().Unmap()
|
||||||
|
p = netip.PrefixFrom(a, min(p.Bits(), a.BitLen())).Masked()
|
||||||
|
switch {
|
||||||
|
case a.Is4() && !a.IsPrivate():
|
||||||
|
continue
|
||||||
|
case a.Is6() && (a.IsLinkLocalUnicast() || a.IsLoopback() || p.Bits() < 48):
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !slices.Contains(out, p) {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// readSysctl returns the trimmed content of a /proc/sys file, or "".
|
// readSysctl returns the trimmed content of a /proc/sys file, or "".
|
||||||
func readSysctl(path string) string {
|
func readSysctl(path string) string {
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
@@ -56,6 +79,10 @@ type Reconciler struct {
|
|||||||
store *Store
|
store *Store
|
||||||
trigger chan struct{}
|
trigger chan struct{}
|
||||||
|
|
||||||
|
// applyMu runs one apply at a time. Each reads the config once it holds
|
||||||
|
// the lock, so the last apply always uses the newest config.
|
||||||
|
applyMu sync.Mutex
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
lastErr error
|
lastErr error
|
||||||
lastApply time.Time
|
lastApply time.Time
|
||||||
@@ -76,6 +103,8 @@ func (r *Reconciler) Kick() {
|
|||||||
// ApplyNow applies synchronously and returns the result, so an API call can
|
// ApplyNow applies synchronously and returns the result, so an API call can
|
||||||
// report kernel errors to the user.
|
// report kernel errors to the user.
|
||||||
func (r *Reconciler) ApplyNow() error {
|
func (r *Reconciler) ApplyNow() error {
|
||||||
|
r.applyMu.Lock()
|
||||||
|
defer r.applyMu.Unlock()
|
||||||
err := r.kernel.Apply(r.store.Get())
|
err := r.kernel.Apply(r.store.Get())
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
r.lastErr, r.lastApply = err, time.Now()
|
r.lastErr, r.lastApply = err, time.Now()
|
||||||
|
|||||||
+20
-19
@@ -217,7 +217,8 @@ func (k *linuxKernel) Apply(c *Config) error {
|
|||||||
if c.Server.IPv6Enabled {
|
if c.Server.IPv6Enabled {
|
||||||
_ = os.WriteFile("/proc/sys/net/ipv6/conf/all/forwarding", []byte("1"), 0o644)
|
_ = os.WriteFile("/proc/sys/net/ipv6/conf/all/forwarding", []byte("1"), 0o644)
|
||||||
}
|
}
|
||||||
return applyFirewall(c, k.Uplink(c, false), k.Uplink(c, true), lanNetworks(k.Uplink(c, false)))
|
up4, up6 := k.Uplink(c, false), k.Uplink(c, true)
|
||||||
|
return applyFirewall(c, up4, up6, lanNetworks(up4, up6))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (k *linuxKernel) Sample(iface string) ([]PeerSample, error) {
|
func (k *linuxKernel) Sample(iface string) ([]PeerSample, error) {
|
||||||
@@ -264,27 +265,27 @@ func (k *linuxKernel) Uplink(c *Config, v6 bool) string {
|
|||||||
return l.Attrs().Name
|
return l.Attrs().Name
|
||||||
}
|
}
|
||||||
|
|
||||||
// lanNetworks returns the private IPv4 networks on the uplink, used to block
|
// lanNetworks returns the LAN networks on the IPv4 and IPv6 uplinks (see
|
||||||
// peers from the server's LAN when LAN access is off.
|
// lanBlock), used to block peers from the server's LAN when LAN access is off.
|
||||||
func lanNetworks(uplink string) []netip.Prefix {
|
func lanNetworks(uplinks ...string) []netip.Prefix {
|
||||||
if uplink == "" {
|
var nets []netip.Prefix
|
||||||
return nil
|
for i, uplink := range uplinks {
|
||||||
}
|
if uplink == "" || slices.Contains(uplinks[:i], uplink) {
|
||||||
l, err := netlink.LinkByName(uplink)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
addrs, _ := netlink.AddrList(l, netlink.FAMILY_V4)
|
|
||||||
var out []netip.Prefix
|
|
||||||
for _, a := range addrs {
|
|
||||||
if !a.IP.IsPrivate() {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ones, _ := a.Mask.Size()
|
l, err := netlink.LinkByName(uplink)
|
||||||
ip, _ := netip.AddrFromSlice(a.IP.To4())
|
if err != nil {
|
||||||
out = append(out, netip.PrefixFrom(ip, ones).Masked())
|
continue
|
||||||
|
}
|
||||||
|
addrs, _ := netlink.AddrList(l, netlink.FAMILY_ALL)
|
||||||
|
for _, a := range addrs {
|
||||||
|
ones, _ := a.Mask.Size()
|
||||||
|
if ip, ok := netip.AddrFromSlice(a.IP); ok {
|
||||||
|
nets = append(nets, netip.PrefixFrom(ip.Unmap(), ones))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return out
|
return lanBlock(nets)
|
||||||
}
|
}
|
||||||
|
|
||||||
// publicAddr reports the uplink's address for the health check: the first
|
// publicAddr reports the uplink's address for the health check: the first
|
||||||
|
|||||||
@@ -224,6 +224,10 @@ func run(configPath string) error {
|
|||||||
app := &App{
|
app := &App{
|
||||||
store: store, kernel: kernel, recon: recon, stats: stats, speeds: speeds, auth: auth, tls: webTLS,
|
store: store, kernel: kernel, recon: recon, stats: stats, speeds: speeds, auth: auth, tls: webTLS,
|
||||||
logPath: logPath, logw: logw, geo: geo, updates: newUpdater(cfg.Updates), started: time.Now(), shutdown: shutdown,
|
logPath: logPath, logw: logw, geo: geo, updates: newUpdater(cfg.Updates), started: time.Now(), shutdown: shutdown,
|
||||||
|
webAddrs: []string{cfg.Web.Listen},
|
||||||
|
}
|
||||||
|
if cfg.Web.HTTPListen != "" && cfg.Web.TLS.Mode != "off" {
|
||||||
|
app.webAddrs = append(app.webAddrs, cfg.Web.HTTPListen)
|
||||||
}
|
}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|||||||
+268
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -79,6 +80,15 @@ func TestValidate(t *testing.T) {
|
|||||||
"bad port": func(c *Config) { c.Server.ListenPort = 70000 },
|
"bad port": func(c *Config) { c.Server.ListenPort = 70000 },
|
||||||
"unmasked net": func(c *Config) { c.Server.IPv4 = "10.84.12.5/24" },
|
"unmasked net": func(c *Config) { c.Server.IPv4 = "10.84.12.5/24" },
|
||||||
"update source": func(c *Config) { c.Updates.Source = "sourceforge" },
|
"update source": func(c *Config) { c.Updates.Source = "sourceforge" },
|
||||||
|
// The endpoint goes into client configs: no extra lines.
|
||||||
|
"endpoint newline": func(c *Config) { c.Server.Endpoint = "vpn.example.net\n[Interface]\nPreUp=id;#" },
|
||||||
|
"endpoint tab": func(c *Config) { c.Server.Endpoint = "vpn.example.net\tx" },
|
||||||
|
"endpoint port": func(c *Config) { c.Server.Endpoint = "vpn.example.net:51820" },
|
||||||
|
"listen": func(c *Config) { c.Web.Listen = "not-an-address" },
|
||||||
|
"listen port": func(c *Config) { c.Web.Listen = ":70000" },
|
||||||
|
"http listen": func(c *Config) { c.Web.HTTPListen = "80" },
|
||||||
|
"session hours": func(c *Config) { c.Web.SessionHours = -1 },
|
||||||
|
"session too long": func(c *Config) { c.Web.SessionHours = 100000 },
|
||||||
} {
|
} {
|
||||||
cc := c.clone()
|
cc := c.clone()
|
||||||
mutate(cc)
|
mutate(cc)
|
||||||
@@ -86,6 +96,20 @@ func TestValidate(t *testing.T) {
|
|||||||
t.Errorf("%s: expected an error", name)
|
t.Errorf("%s: expected an error", name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for _, ep := range []string{"vpn.example.net", "203.0.113.7", "2001:db8::1"} {
|
||||||
|
cc := c.clone()
|
||||||
|
cc.Server.Endpoint = ep
|
||||||
|
if err := cc.validate(); err != nil {
|
||||||
|
t.Errorf("endpoint %q rejected: %v", ep, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, l := range []string{":443", "0.0.0.0:8443", "[::]:443", "localhost:8080"} {
|
||||||
|
cc := c.clone()
|
||||||
|
cc.Web.Listen = l
|
||||||
|
if err := cc.validate(); err != nil {
|
||||||
|
t.Errorf("listen %q rejected: %v", l, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestClientConfig(t *testing.T) {
|
func TestClientConfig(t *testing.T) {
|
||||||
@@ -1339,3 +1363,247 @@ func TestSpeeds(t *testing.T) {
|
|||||||
t.Fatalf("kept %d points, want %d", n, speedPoints)
|
t.Fatalf("kept %d points, want %d", n, speedPoints)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// signedInApp starts the API with a signed-in admin and returns the app
|
||||||
|
// and a call function.
|
||||||
|
func signedInApp(t *testing.T) (*App, func(method, path string, body any, want int) map[string]any) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := openStore(filepath.Join(dir, "config.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
hash, _ := hashPassword("a long test password")
|
||||||
|
_ = store.Update(func(c *Config) error { c.Users[0].PasswordHash = hash; return nil })
|
||||||
|
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())
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
jar, _ := cookiejar.New(nil)
|
||||||
|
cl := &http.Client{Jar: jar}
|
||||||
|
call := func(method, path string, body any, want int) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var rd io.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
rd = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(method, srv.URL+"/api/v1"+path, rd)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := cl.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var out map[string]any
|
||||||
|
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||||
|
if resp.StatusCode != want {
|
||||||
|
t.Fatalf("%s %s: status %d, want %d: %v", method, path, resp.StatusCode, want, out)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
call("POST", "/auth/login", map[string]string{"username": "admin", "password": "a long test password"}, 200)
|
||||||
|
return app, call
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web settings the service could not start with are refused before they
|
||||||
|
// are saved: a restart would otherwise take the web interface and the API
|
||||||
|
// down for good.
|
||||||
|
func TestWebSettingsCheck(t *testing.T) {
|
||||||
|
app, call := signedInApp(t)
|
||||||
|
web := func(change func(w *WebConfig)) map[string]any {
|
||||||
|
w := app.store.Get().Web
|
||||||
|
w.HTTPListen = ""
|
||||||
|
change(&w)
|
||||||
|
return map[string]any{"web": w}
|
||||||
|
}
|
||||||
|
call("PATCH", "/settings", web(func(w *WebConfig) { w.Listen = "not-an-address" }), 400)
|
||||||
|
call("PATCH", "/settings", web(func(w *WebConfig) { w.SessionHours = -1 }), 400)
|
||||||
|
call("PATCH", "/settings", web(func(w *WebConfig) {
|
||||||
|
w.TLS = TLSConfig{Mode: "files", CertFile: "/nonexistent/cert.pem", KeyFile: "/nonexistent/key.pem"}
|
||||||
|
}), 400)
|
||||||
|
|
||||||
|
// A port another program holds is refused; a free one is saved.
|
||||||
|
busy, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer busy.Close()
|
||||||
|
call("PATCH", "/settings", web(func(w *WebConfig) { w.Listen = busy.Addr().String() }), 400)
|
||||||
|
free, _ := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
addr := free.Addr().String()
|
||||||
|
free.Close()
|
||||||
|
call("PATCH", "/settings", web(func(w *WebConfig) { w.Listen = addr; w.TLS = TLSConfig{Mode: "off"} }), 200)
|
||||||
|
if app.store.Get().Web.Listen != addr {
|
||||||
|
t.Fatal("valid listen address not saved")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The address the service listens on now is in use by itself: fine.
|
||||||
|
app.webAddrs = []string{busy.Addr().String()}
|
||||||
|
call("PATCH", "/settings", web(func(w *WebConfig) { w.Listen = busy.Addr().String() }), 200)
|
||||||
|
|
||||||
|
// Restore runs the same check.
|
||||||
|
backup := app.store.Get()
|
||||||
|
backup.Web.Listen = "not-an-address"
|
||||||
|
call("POST", "/restore", backup, 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteIP(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
remote string
|
||||||
|
xff []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"203.0.113.5:1234", nil, "203.0.113.5"},
|
||||||
|
{"203.0.113.5:1234", []string{"198.51.100.1"}, "203.0.113.5"}, // not from a local proxy
|
||||||
|
{"127.0.0.1:1234", []string{"198.51.100.1"}, "198.51.100.1"},
|
||||||
|
// The client sent its own header; the proxy appended the real address.
|
||||||
|
{"127.0.0.1:1234", []string{"1.2.3.4, 198.51.100.1"}, "198.51.100.1"},
|
||||||
|
{"127.0.0.1:1234", []string{"1.2.3.4", "198.51.100.1"}, "198.51.100.1"},
|
||||||
|
{"127.0.0.1:1234", []string{"garbage"}, "127.0.0.1"},
|
||||||
|
} {
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.RemoteAddr = c.remote
|
||||||
|
for _, v := range c.xff {
|
||||||
|
r.Header.Add("X-Forwarded-For", v)
|
||||||
|
}
|
||||||
|
if got := remoteIP(r); got != c.want {
|
||||||
|
t.Errorf("%s %v: got %s, want %s", c.remote, c.xff, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anyone can start a passkey sign-in, so pending ones are capped per
|
||||||
|
// address and in total.
|
||||||
|
func TestPasskeyLoginCap(t *testing.T) {
|
||||||
|
a := newAuth(nil)
|
||||||
|
start := func(id, ip string, expires time.Time) bool {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
return a.addPasskeyLoginLocked(id, &ceremony{ip: lockKey(ip), expires: expires})
|
||||||
|
}
|
||||||
|
later := time.Now().Add(ticketTTL)
|
||||||
|
for i := range maxPasskeyLoginsPerIP {
|
||||||
|
if !start(fmt.Sprint("a", i), "198.51.100.1", later) {
|
||||||
|
t.Fatalf("sign-in %d refused", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if start("a-more", "198.51.100.1", later) {
|
||||||
|
t.Fatal("too many sign-ins from one address accepted")
|
||||||
|
}
|
||||||
|
if !start("b0", "198.51.100.2", later) {
|
||||||
|
t.Fatal("another address refused")
|
||||||
|
}
|
||||||
|
// Expired ones make room again.
|
||||||
|
a.mfa.logins = map[string]*ceremony{}
|
||||||
|
start("old", "198.51.100.3", time.Now().Add(-time.Second))
|
||||||
|
if !start("new", "198.51.100.3", later) || len(a.mfa.logins) != 1 {
|
||||||
|
t.Fatalf("expired sign-in not dropped: %d pending", len(a.mfa.logins))
|
||||||
|
}
|
||||||
|
// In total, the oldest makes room.
|
||||||
|
a.mfa.logins = map[string]*ceremony{}
|
||||||
|
for i := range maxPasskeyLogins {
|
||||||
|
start(fmt.Sprint("c", i), fmt.Sprintf("10.0.%d.%d", i/250, i%250), later.Add(time.Duration(i)*time.Millisecond))
|
||||||
|
}
|
||||||
|
start("last", "192.0.2.1", later.Add(time.Hour))
|
||||||
|
if _, ok := a.mfa.logins["c0"]; ok || len(a.mfa.logins) != maxPasskeyLogins {
|
||||||
|
t.Fatalf("cap not kept: %d pending, oldest kept %v", len(a.mfa.logins), ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLanBlock(t *testing.T) {
|
||||||
|
got := lanBlock([]netip.Prefix{
|
||||||
|
netip.MustParsePrefix("192.168.1.20/24"),
|
||||||
|
netip.MustParsePrefix("203.0.113.9/24"), // public IPv4: not a LAN
|
||||||
|
netip.MustParsePrefix("2001:db8:1:2::20/64"),
|
||||||
|
netip.MustParsePrefix("fd00:1:2:3::20/64"),
|
||||||
|
netip.MustParsePrefix("fe80::1/64"),
|
||||||
|
netip.MustParsePrefix("2001:db8::1/32"), // no LAN
|
||||||
|
netip.MustParsePrefix("192.168.1.30/24"), // same network twice
|
||||||
|
})
|
||||||
|
want := []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("192.168.1.0/24"),
|
||||||
|
netip.MustParsePrefix("2001:db8:1:2::/64"),
|
||||||
|
netip.MustParsePrefix("fd00:1:2:3::/64"),
|
||||||
|
}
|
||||||
|
if !slices.Equal(got, want) {
|
||||||
|
t.Fatalf("got %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A change that would leave no user with a password is refused: restoring
|
||||||
|
// a backup without users, or the last users deleting each other.
|
||||||
|
func TestNoUserLeftWithPassword(t *testing.T) {
|
||||||
|
app, call := signedInApp(t)
|
||||||
|
if err := app.store.Update(func(c *Config) error { c.Users = nil; return nil }); err == nil {
|
||||||
|
t.Fatal("removing every user was accepted")
|
||||||
|
}
|
||||||
|
if !app.store.Get().passwordSet() {
|
||||||
|
t.Fatal("password lost")
|
||||||
|
}
|
||||||
|
|
||||||
|
backup := app.store.Get()
|
||||||
|
backup.Users, backup.APITokens = nil, nil
|
||||||
|
call("POST", "/restore", backup, 400)
|
||||||
|
backup = app.store.Get()
|
||||||
|
backup.Version = configVersion + 1
|
||||||
|
call("POST", "/restore", backup, 400)
|
||||||
|
call("POST", "/restore", app.store.Get(), 200)
|
||||||
|
if !app.store.Get().passwordSet() {
|
||||||
|
t.Fatal("password lost")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// slowKernel records the configs it applied; the first apply takes a while.
|
||||||
|
type slowKernel struct {
|
||||||
|
fakeKernel
|
||||||
|
mu sync.Mutex
|
||||||
|
calls int
|
||||||
|
applied []string // peer names, per apply
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *slowKernel) Apply(c *Config) error {
|
||||||
|
k.mu.Lock()
|
||||||
|
k.calls++
|
||||||
|
first := k.calls == 1
|
||||||
|
k.mu.Unlock()
|
||||||
|
if first {
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, p := range c.Peers {
|
||||||
|
names = append(names, p.Name)
|
||||||
|
}
|
||||||
|
k.mu.Lock()
|
||||||
|
k.applied = append(k.applied, strings.Join(names, ","))
|
||||||
|
k.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Applies run one at a time, so a slow apply of an older config cannot
|
||||||
|
// finish after the newest one and undo it in the kernel.
|
||||||
|
func TestApplyOrder(t *testing.T) {
|
||||||
|
store, err := openStore(filepath.Join(t.TempDir(), "config.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
k := &slowKernel{}
|
||||||
|
r := newReconciler(k, store)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
go func() { defer wg.Done(); _ = r.ApplyNow() }() // the old config, slowly
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
if err := store.Update(func(c *Config) error {
|
||||||
|
c.Peers = append(c.Peers, Peer{ID: newID(), Name: "phone", IPv4: serverIPv4(netip.MustParsePrefix(c.Server.IPv4)).Next().String()})
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = r.ApplyNow()
|
||||||
|
wg.Wait()
|
||||||
|
if last := k.applied[len(k.applied)-1]; last != "phone" {
|
||||||
|
t.Fatalf("the kernel ended with %q, not the newest config; applies: %q", last, k.applied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ type ticket struct {
|
|||||||
|
|
||||||
type ceremony struct {
|
type ceremony struct {
|
||||||
userID string // "" for a passkey sign-in
|
userID string // "" for a passkey sign-in
|
||||||
|
ip string // lockKey of who started a passkey sign-in
|
||||||
data *webauthn.SessionData
|
data *webauthn.SessionData
|
||||||
expires time.Time
|
expires time.Time
|
||||||
}
|
}
|
||||||
@@ -551,12 +552,52 @@ func (a *App) loginPasskeyBegin(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
id := randomString(24)
|
id := randomString(24)
|
||||||
|
ip := remoteIP(r)
|
||||||
a.auth.mu.Lock()
|
a.auth.mu.Lock()
|
||||||
a.auth.mfa.logins[id] = &ceremony{data: data, expires: time.Now().Add(ticketTTL)}
|
ok := a.auth.addPasskeyLoginLocked(id, &ceremony{data: data, ip: lockKey(ip), expires: time.Now().Add(ticketTTL)})
|
||||||
a.auth.mu.Unlock()
|
a.auth.mu.Unlock()
|
||||||
|
if !ok {
|
||||||
|
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": errBusy.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"id": id, "options": opts})
|
writeJSON(w, http.StatusOK, map[string]any{"id": id, "options": opts})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anyone can start a passkey sign-in, so the pending ones are capped: per
|
||||||
|
// address, and in total, where the oldest makes room.
|
||||||
|
const (
|
||||||
|
maxPasskeyLogins = 1000
|
||||||
|
maxPasskeyLoginsPerIP = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// addPasskeyLoginLocked stores a started passkey sign-in, or reports false
|
||||||
|
// when its address has too many pending. a.mu must be held.
|
||||||
|
func (a *Auth) addPasskeyLoginLocked(id string, c *ceremony) bool {
|
||||||
|
now := time.Now()
|
||||||
|
var fromIP int
|
||||||
|
var oldestID string
|
||||||
|
for k, x := range a.mfa.logins {
|
||||||
|
if now.After(x.expires) {
|
||||||
|
delete(a.mfa.logins, k)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if x.ip == c.ip {
|
||||||
|
fromIP++
|
||||||
|
}
|
||||||
|
if oldestID == "" || x.expires.Before(a.mfa.logins[oldestID].expires) {
|
||||||
|
oldestID = k
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fromIP >= maxPasskeyLoginsPerIP {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if len(a.mfa.logins) >= maxPasskeyLogins {
|
||||||
|
delete(a.mfa.logins, oldestID)
|
||||||
|
}
|
||||||
|
a.mfa.logins[id] = c
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
|
func (a *App) loginPasskeyFinish(w http.ResponseWriter, r *http.Request) {
|
||||||
id := r.URL.Query().Get("id")
|
id := r.URL.Query().Get("id")
|
||||||
ip := remoteIP(r)
|
ip := remoteIP(r)
|
||||||
|
|||||||
@@ -20,6 +20,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Same drawing as favicon.svg.
|
// Same drawing as favicon.svg.
|
||||||
|
// ext opens an outside page in a new tab, marked with ↗ as in the app.
|
||||||
|
const ext = (href, text) => h('a', { class: 'ext', href, target: '_blank', rel: 'noopener' }, text,
|
||||||
|
h('span', { class: 'ar', 'aria-hidden': 'true' }, '↗'), h('span', { class: 'sr' }, ' (opens in a new tab)'));
|
||||||
|
|
||||||
function logo(size, plain) {
|
function logo(size, plain) {
|
||||||
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
for (const [k, v] of Object.entries({ width: size, height: size, viewBox: '0 0 64 64', 'aria-hidden': 'true' })) s.setAttribute(k, v);
|
for (const [k, v] of Object.entries({ width: size, height: size, viewBox: '0 0 64 64', 'aria-hidden': 'true' })) s.setAttribute(k, v);
|
||||||
@@ -108,9 +112,9 @@
|
|||||||
h('div', { class: 'notice' }, 'Save it now. This page can\'t be opened again: the private key exists only here and isn\'t stored anywhere.'),
|
h('div', { class: 'notice' }, 'Save it now. This page can\'t be opened again: the private key exists only here and isn\'t stored anywhere.'),
|
||||||
h('ol', { class: 'steps' },
|
h('ol', { class: 'steps' },
|
||||||
step(1, 'Install WireGuard',
|
step(1, 'Install WireGuard',
|
||||||
h('p', null, h('a', { href: 'https://apps.apple.com/app/wireguard/id1441195209', rel: 'noopener' }, 'App Store'), ' · ',
|
h('p', null, ext('https://apps.apple.com/app/wireguard/id1441195209', 'App Store'), ' · ',
|
||||||
h('a', { href: 'https://play.google.com/store/apps/details?id=com.wireguard.android', rel: 'noopener' }, 'Google Play'), ' · ',
|
ext('https://play.google.com/store/apps/details?id=com.wireguard.android', 'Google Play'), ' · ',
|
||||||
h('a', { href: 'https://www.wireguard.com/install/', rel: 'noopener' }, 'Other systems'))),
|
ext('https://www.wireguard.com/install/', 'Other systems'))),
|
||||||
step(2, 'Add the profile',
|
step(2, 'Add the profile',
|
||||||
h('button', { type: 'button', class: 'btn primary', onClick: download }, 'Download ' + file),
|
h('button', { type: 'button', class: 'btn primary', onClick: download }, 'Download ' + file),
|
||||||
h('p', null, 'Open the downloaded file with WireGuard, or in WireGuard tap + and choose “Create from file”.')),
|
h('p', null, 'Open the downloaded file with WireGuard, or in WireGuard tap + and choose “Create from file”.')),
|
||||||
|
|||||||
Reference in New Issue
Block a user