Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e8dba6072 | |||
| aa4ca20296 | |||
| 1bfede250c | |||
| 8edde9f5e7 |
@@ -3,15 +3,18 @@ package main
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -30,6 +33,7 @@ type App struct {
|
||||
updates *Updater // nil in tests
|
||||
started time.Time
|
||||
shutdown func() // graceful stop; systemd restarts the service
|
||||
webAddrs []string // the addresses the web server listens on now
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
@@ -353,7 +357,7 @@ func (a *App) status(w http.ResponseWriter, r *http.Request) {
|
||||
d30, u30 := sumPoints(a.stats.series(nil, "30d"))
|
||||
checks := a.kernel.Checks(cfg)
|
||||
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 {
|
||||
ac.Detail = applyErr.Error()
|
||||
}
|
||||
@@ -1090,11 +1094,15 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := json.Marshal(w)
|
||||
return string(b)
|
||||
}
|
||||
before := listen()
|
||||
before, oldWeb := listen(), c.Web
|
||||
if err := field(m, "web", &c.Web); err != nil {
|
||||
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 {
|
||||
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?"))
|
||||
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)
|
||||
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})
|
||||
}
|
||||
|
||||
// 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
|
||||
// level and log rotation. Traffic retention is read by the stats sampler.
|
||||
func (a *App) applyRuntime(c *Config) {
|
||||
|
||||
@@ -33,8 +33,20 @@
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; }
|
||||
body { background: var(--ground); color: var(--ink); font-family: var(--sans); font-size: 14px; line-height: 1.45; }
|
||||
a { color: var(--link); }
|
||||
a:hover { color: var(--link-hover); }
|
||||
a { color: var(--link); text-decoration-color: rgba(28, 92, 171, .35); text-underline-offset: 3px; }
|
||||
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; }
|
||||
[hidden] { display: none !important; }
|
||||
.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; }
|
||||
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); }
|
||||
.back { font-size: 13px; margin-bottom: -8px; }
|
||||
.back { margin-bottom: -8px; align-self: flex-start; }
|
||||
|
||||
/* cards */
|
||||
.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; }
|
||||
td .note { font-size: 12px; color: var(--ink-3); }
|
||||
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); }
|
||||
|
||||
/* 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 h1 { font-size: 22px; }
|
||||
.setupbox p { margin: 0; color: #c9c9c3; }
|
||||
.setupbox a { color: #9cc3f5; }
|
||||
.setupbox a:hover { color: #fff; }
|
||||
.setupbox a { color: #9cc3f5; text-decoration-color: rgba(156, 195, 245, .4); }
|
||||
.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 { gap: 20px; }
|
||||
.setupbox .ghost { opacity: .45; }
|
||||
|
||||
@@ -169,6 +169,14 @@
|
||||
|
||||
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.
|
||||
function svg(tag, attrs, ...kids) {
|
||||
const el = document.createElementNS('http://www.w3.org/2000/svg', tag);
|
||||
@@ -1086,17 +1094,17 @@
|
||||
|
||||
h('div', { class: 'cols' },
|
||||
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' },
|
||||
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('td', null, h('a', { href: '#/peers/' + p.id }, p.name)),
|
||||
h('td', null, peerLink(p)),
|
||||
h('td', null, badge(peerState(p))),
|
||||
h('td', { class: 'num' }, fmtBytes(p.stats.down24h)),
|
||||
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' },
|
||||
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
|
||||
? 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));
|
||||
@@ -1294,7 +1302,7 @@
|
||||
h('tbody', null, online.map(({ p, r, hist }) => {
|
||||
const idle = r[0] + r[1] < IDLE_BPS;
|
||||
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', { class: 'num' }, rate(r[0], idle)),
|
||||
h('td', { class: 'num' }, rate(r[1], idle)),
|
||||
@@ -1424,7 +1432,7 @@
|
||||
return hit && keep;
|
||||
});
|
||||
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', null, badge(peerState(p))),
|
||||
h('td', { class: 'mono muted' }, p.stats.endpoint || '–',
|
||||
@@ -1579,7 +1587,7 @@
|
||||
drawPreview();
|
||||
|
||||
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', { class: 'split' }, form,
|
||||
h('aside', { class: 'card aside', 'aria-labelledby': 'pv' },
|
||||
@@ -1736,7 +1744,7 @@
|
||||
};
|
||||
|
||||
fill(wrap,
|
||||
h('a', { class: 'back', href: '#/peers' }, '← Peers'),
|
||||
back('#/peers', 'Peers'),
|
||||
h('div', { class: 'head' },
|
||||
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)),
|
||||
@@ -1787,7 +1795,7 @@
|
||||
: h('p', { class: 'empty' }, 'No connections recorded yet.'),
|
||||
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('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.')),
|
||||
|
||||
h('form', { class: 'card', onSubmit: save },
|
||||
@@ -1847,8 +1855,8 @@
|
||||
t.status = c.ok ? 'Present' : 'Missing';
|
||||
if (/^table /.test(c.detail)) { t.raw = c.detail.replace(/ (present|missing)$/, ''); t.problem = null; }
|
||||
break;
|
||||
case 'Last apply':
|
||||
if (c.ok) { const iso = c.detail.replace(/^applied /, ''); t.status = ago(iso); t.title = fmtStamp(iso); } else t.status = 'Failed';
|
||||
case 'Kernel in sync':
|
||||
if (c.ok) { const iso = c.detail.replace(/^applied /, ''); t.status = ago(iso); t.title = fmtStamp(iso); } else t.status = 'Out of sync';
|
||||
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') })),
|
||||
cb('nat', 'Masquerade (NAT) peer traffic to the internet'),
|
||||
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'))),
|
||||
|
||||
h('section', { class: 'card', 'aria-labelledby': 'ky' },
|
||||
@@ -2117,7 +2125,7 @@
|
||||
notes ? h('div', { class: 'upnotes' },
|
||||
h('div', { class: 'hd' }, h('strong', null, 'What\'s new in ' + rel.version),
|
||||
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,
|
||||
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,
|
||||
@@ -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('pre', { class: 'code' }, cmds),
|
||||
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('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 }) }),
|
||||
|
||||
@@ -287,9 +287,14 @@ func remoteIP(r *http.Request) string {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
// 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 xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
return strings.TrimSpace(strings.Split(xff, ",")[0])
|
||||
if xff := r.Header.Values("X-Forwarded-For"); len(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
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
@@ -76,8 +77,29 @@ const (
|
||||
minLogFiles, maxLogFiles = 1, 100
|
||||
minHourlyHours, maxHourlyHrs = 24, 24 * 31
|
||||
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 {
|
||||
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
|
||||
@@ -364,7 +386,9 @@ func (c *Config) validate() error {
|
||||
if v6.Masked() != v6 {
|
||||
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")
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
case "acme":
|
||||
if c.Web.TLS.Domain == "" {
|
||||
@@ -586,6 +619,12 @@ func (s *Store) Update(fn func(c *Config) error) error {
|
||||
s.mu.Unlock()
|
||||
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 {
|
||||
s.mu.Unlock()
|
||||
return err
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -40,6 +41,28 @@ type Kernel interface {
|
||||
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 "".
|
||||
func readSysctl(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
@@ -56,6 +79,10 @@ type Reconciler struct {
|
||||
store *Store
|
||||
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
|
||||
lastErr error
|
||||
lastApply time.Time
|
||||
@@ -76,6 +103,8 @@ func (r *Reconciler) Kick() {
|
||||
// ApplyNow applies synchronously and returns the result, so an API call can
|
||||
// report kernel errors to the user.
|
||||
func (r *Reconciler) ApplyNow() error {
|
||||
r.applyMu.Lock()
|
||||
defer r.applyMu.Unlock()
|
||||
err := r.kernel.Apply(r.store.Get())
|
||||
r.mu.Lock()
|
||||
r.lastErr, r.lastApply = err, time.Now()
|
||||
|
||||
+16
-15
@@ -217,7 +217,8 @@ func (k *linuxKernel) Apply(c *Config) error {
|
||||
if c.Server.IPv6Enabled {
|
||||
_ = 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) {
|
||||
@@ -264,27 +265,27 @@ func (k *linuxKernel) Uplink(c *Config, v6 bool) string {
|
||||
return l.Attrs().Name
|
||||
}
|
||||
|
||||
// lanNetworks returns the private IPv4 networks on the uplink, used to block
|
||||
// peers from the server's LAN when LAN access is off.
|
||||
func lanNetworks(uplink string) []netip.Prefix {
|
||||
if uplink == "" {
|
||||
return nil
|
||||
// lanNetworks returns the LAN networks on the IPv4 and IPv6 uplinks (see
|
||||
// lanBlock), used to block peers from the server's LAN when LAN access is off.
|
||||
func lanNetworks(uplinks ...string) []netip.Prefix {
|
||||
var nets []netip.Prefix
|
||||
for i, uplink := range uplinks {
|
||||
if uplink == "" || slices.Contains(uplinks[:i], uplink) {
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
addrs, _ := netlink.AddrList(l, netlink.FAMILY_ALL)
|
||||
for _, a := range addrs {
|
||||
ones, _ := a.Mask.Size()
|
||||
ip, _ := netip.AddrFromSlice(a.IP.To4())
|
||||
out = append(out, netip.PrefixFrom(ip, ones).Masked())
|
||||
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
|
||||
|
||||
@@ -224,6 +224,10 @@ func run(configPath string) error {
|
||||
app := &App{
|
||||
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,
|
||||
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
|
||||
|
||||
+268
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/http/httptest"
|
||||
@@ -79,6 +80,15 @@ func TestValidate(t *testing.T) {
|
||||
"bad port": func(c *Config) { c.Server.ListenPort = 70000 },
|
||||
"unmasked net": func(c *Config) { c.Server.IPv4 = "10.84.12.5/24" },
|
||||
"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()
|
||||
mutate(cc)
|
||||
@@ -86,6 +96,20 @@ func TestValidate(t *testing.T) {
|
||||
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) {
|
||||
@@ -1339,3 +1363,247 @@ func TestSpeeds(t *testing.T) {
|
||||
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 {
|
||||
userID string // "" for a passkey sign-in
|
||||
ip string // lockKey of who started a passkey sign-in
|
||||
data *webauthn.SessionData
|
||||
expires time.Time
|
||||
}
|
||||
@@ -551,12 +552,52 @@ func (a *App) loginPasskeyBegin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
id := randomString(24)
|
||||
ip := remoteIP(r)
|
||||
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()
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": errBusy.Error()})
|
||||
return
|
||||
}
|
||||
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) {
|
||||
id := r.URL.Query().Get("id")
|
||||
ip := remoteIP(r)
|
||||
|
||||
@@ -20,6 +20,10 @@
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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);
|
||||
@@ -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('ol', { class: 'steps' },
|
||||
step(1, 'Install WireGuard',
|
||||
h('p', null, h('a', { href: 'https://apps.apple.com/app/wireguard/id1441195209', rel: 'noopener' }, 'App Store'), ' · ',
|
||||
h('a', { href: 'https://play.google.com/store/apps/details?id=com.wireguard.android', rel: 'noopener' }, 'Google Play'), ' · ',
|
||||
h('a', { href: 'https://www.wireguard.com/install/', rel: 'noopener' }, 'Other systems'))),
|
||||
h('p', null, ext('https://apps.apple.com/app/wireguard/id1441195209', 'App Store'), ' · ',
|
||||
ext('https://play.google.com/store/apps/details?id=com.wireguard.android', 'Google Play'), ' · ',
|
||||
ext('https://www.wireguard.com/install/', 'Other systems'))),
|
||||
step(2, 'Add the profile',
|
||||
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”.')),
|
||||
|
||||
Reference in New Issue
Block a user