Peers: optional latency check with per-peer setting
The server pings a peer's tunnel address every 30 s and shows the median of the last 5 minutes in the peer list (with a 1-hour sparkline) and a 24-hour chart on the peer page. Off by default; "active" pings only while the device sends traffic, "always" keeps the tunnel up.
This commit is contained in:
@@ -221,6 +221,7 @@ GET /peers POST /peers (returns the config and QR once)
|
|||||||
GET /peers/{id} PATCH /peers/{id} DELETE /peers/{id}
|
GET /peers/{id} PATCH /peers/{id} DELETE /peers/{id}
|
||||||
POST /peers/{id}/enable | /disable | /issue-config
|
POST /peers/{id}/enable | /disable | /issue-config
|
||||||
GET /peers/{id}/stats?range=… GET /peers/{id}/sessions?limit=100
|
GET /peers/{id}/stats?range=… GET /peers/{id}/sessions?limit=100
|
||||||
|
GET /peers/{id}/latency (24 h, one point per 5 minutes)
|
||||||
GET /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup
|
GET /peers/{id}/setup (not read-only) DELETE /peers/{id}/setup
|
||||||
GET /settings PATCH /settings POST /restart
|
GET /settings PATCH /settings POST /restart
|
||||||
GET /logs?level=&limit=&audit=1 GET /logs/download
|
GET /logs?level=&limit=&audit=1 GET /logs/download
|
||||||
@@ -236,6 +237,15 @@ link, the peer's current keys keep working until the link is opened.
|
|||||||
Traffic is reported from the peer's point of view: `down` is what the peer
|
Traffic is reported from the peer's point of view: `down` is what the peer
|
||||||
downloaded, `up` is what it uploaded.
|
downloaded, `up` is what it uploaded.
|
||||||
|
|
||||||
|
Latency is measured by pinging the peer's tunnel address every 30 seconds. Set
|
||||||
|
it per peer with `PATCH /peers/{id}` `{"latencyCheck": "off"|"active"|"always"}`
|
||||||
|
(default `off`). `active` pings only while the device sends traffic, so idle
|
||||||
|
phones are not woken up; `always` keeps the tunnel up, so the peer always shows
|
||||||
|
as online. The service opens an unprivileged ICMP socket, which needs its group
|
||||||
|
in the sysctl `net.ipv4.ping_group_range` (systemd allows all groups by
|
||||||
|
default). Devices that block ping, such as Windows with its default firewall,
|
||||||
|
show no reply.
|
||||||
|
|
||||||
## Firewall note
|
## Firewall note
|
||||||
|
|
||||||
GHOSTWIRE's rules sit in their own nftables table. An accept there cannot
|
GHOSTWIRE's rules sit in their own nftables table. An accept there cannot
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -123,6 +124,7 @@ func (a *App) routes() http.Handler {
|
|||||||
g("POST /api/v1/peers/{id}/disable", a.setEnabled(false))
|
g("POST /api/v1/peers/{id}/disable", a.setEnabled(false))
|
||||||
g("POST /api/v1/peers/{id}/issue-config", a.issueConfig)
|
g("POST /api/v1/peers/{id}/issue-config", a.issueConfig)
|
||||||
g("GET /api/v1/peers/{id}/stats", a.peerStats)
|
g("GET /api/v1/peers/{id}/stats", a.peerStats)
|
||||||
|
g("GET /api/v1/peers/{id}/latency", a.peerLatency)
|
||||||
g("GET /api/v1/peers/{id}/sessions", a.peerSessions)
|
g("GET /api/v1/peers/{id}/sessions", a.peerSessions)
|
||||||
g("GET /api/v1/peers/{id}/setup", a.getSetup)
|
g("GET /api/v1/peers/{id}/setup", a.getSetup)
|
||||||
g("DELETE /api/v1/peers/{id}/setup", a.revokeSetup)
|
g("DELETE /api/v1/peers/{id}/setup", a.revokeSetup)
|
||||||
@@ -269,6 +271,9 @@ func (a *App) status(w http.ResponseWriter, r *http.Request) {
|
|||||||
ac.Detail = applyErr.Error()
|
ac.Detail = applyErr.Error()
|
||||||
}
|
}
|
||||||
checks = append(checks, ac)
|
checks = append(checks, ac)
|
||||||
|
if pc, ok := a.stats.PingCheck(cfg); ok {
|
||||||
|
checks = append(checks, pc)
|
||||||
|
}
|
||||||
healthy := true
|
healthy := true
|
||||||
for _, c := range checks {
|
for _, c := range checks {
|
||||||
healthy = healthy && c.OK
|
healthy = healthy && c.OK
|
||||||
@@ -319,6 +324,26 @@ func (a *App) peerStats(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]any{"range": rng, "points": a.stats.series([]string{r.PathValue("id")}, rng)})
|
writeJSON(w, http.StatusOK, map[string]any{"range": rng, "points": a.stats.series([]string{r.PathValue("id")}, rng)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) peerLatency(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, p := a.store.Get().peerByID(r.PathValue("id")); p == nil {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no such peer"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{"stepSeconds": int(latencyStep.Seconds()), "points": a.stats.LatencyHistory(r.PathValue("id"))})
|
||||||
|
}
|
||||||
|
|
||||||
|
// latencyMode turns the API value into the stored one: "off" (or nothing)
|
||||||
|
// is stored empty.
|
||||||
|
func latencyMode(v string) (string, error) {
|
||||||
|
if v == "off" {
|
||||||
|
return latencyOff, nil
|
||||||
|
}
|
||||||
|
if !validLatencyCheck(v) {
|
||||||
|
return "", badRequest("latencyCheck must be off, active or always")
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- server ---
|
// --- server ---
|
||||||
|
|
||||||
type serverView struct {
|
type serverView struct {
|
||||||
@@ -517,6 +542,7 @@ type peerView struct {
|
|||||||
EffDNS []string `json:"effectiveDNS"`
|
EffDNS []string `json:"effectiveDNS"`
|
||||||
EffAllowed []string `json:"effectiveAllowedIPs"`
|
EffAllowed []string `json:"effectiveAllowedIPs"`
|
||||||
EffKeepalive int `json:"effectiveKeepalive"`
|
EffKeepalive int `json:"effectiveKeepalive"`
|
||||||
|
LatencyCheck string `json:"latencyCheck"` // off | active | always
|
||||||
Created time.Time `json:"created"`
|
Created time.Time `json:"created"`
|
||||||
ConfigIssued *time.Time `json:"configIssued"`
|
ConfigIssued *time.Time `json:"configIssued"`
|
||||||
Setup *setupView `json:"setup"` // null = no pending setup link
|
Setup *setupView `json:"setup"` // null = no pending setup link
|
||||||
@@ -528,6 +554,7 @@ func (a *App) peerView(c *Config, p *Peer) peerView {
|
|||||||
ID: p.ID, Name: p.Name, Note: p.Note, Enabled: p.Enabled, PublicKey: p.PublicKey,
|
ID: p.ID, Name: p.Name, Note: p.Note, Enabled: p.Enabled, PublicKey: p.PublicKey,
|
||||||
HasPSK: p.PresharedKey != "", IPv4: p.IPv4, DNS: p.DNS, AllowedIPs: p.AllowedIPs, Keepalive: p.Keepalive,
|
HasPSK: p.PresharedKey != "", IPv4: p.IPv4, DNS: p.DNS, AllowedIPs: p.AllowedIPs, Keepalive: p.Keepalive,
|
||||||
EffDNS: peerDNS(c, p), EffAllowed: peerAllowedIPs(c, p), EffKeepalive: peerKeepalive(c, p),
|
EffDNS: peerDNS(c, p), EffAllowed: peerAllowedIPs(c, p), EffKeepalive: peerKeepalive(c, p),
|
||||||
|
LatencyCheck: cmp.Or(p.LatencyCheck, "off"),
|
||||||
Created: p.Created, ConfigIssued: p.ConfigIssued, Setup: viewSetup(p.Setup), Stats: a.stats.Summary(p.ID),
|
Created: p.Created, ConfigIssued: p.ConfigIssued, Setup: viewSetup(p.Setup), Stats: a.stats.Summary(p.ID),
|
||||||
}
|
}
|
||||||
if c.Server.IPv6Enabled {
|
if c.Server.IPv6Enabled {
|
||||||
@@ -610,6 +637,7 @@ func (a *App) createPeer(w http.ResponseWriter, r *http.Request) {
|
|||||||
AllowedIPs []string `json:"allowedIPs"`
|
AllowedIPs []string `json:"allowedIPs"`
|
||||||
Keepalive *int `json:"keepalive"`
|
Keepalive *int `json:"keepalive"`
|
||||||
PresharedKey *bool `json:"presharedKey"`
|
PresharedKey *bool `json:"presharedKey"`
|
||||||
|
LatencyCheck string `json:"latencyCheck"`
|
||||||
setupRequest
|
setupRequest
|
||||||
}
|
}
|
||||||
if err := readJSON(r, &in); err != nil {
|
if err := readJSON(r, &in); err != nil {
|
||||||
@@ -617,6 +645,11 @@ func (a *App) createPeer(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
in.Name = strings.TrimSpace(in.Name)
|
in.Name = strings.TrimSpace(in.Name)
|
||||||
|
lc, err := latencyMode(in.LatencyCheck)
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
link, err := in.newLink()
|
link, err := in.newLink()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeErr(w, err)
|
writeErr(w, err)
|
||||||
@@ -624,7 +657,7 @@ func (a *App) createPeer(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
p := Peer{
|
p := Peer{
|
||||||
ID: newID(), Name: in.Name, Note: strings.TrimSpace(in.Note), Enabled: true,
|
ID: newID(), Name: in.Name, Note: strings.TrimSpace(in.Note), Enabled: true,
|
||||||
DNS: in.DNS, AllowedIPs: in.AllowedIPs, Keepalive: in.Keepalive, Created: time.Now().UTC(),
|
DNS: in.DNS, AllowedIPs: in.AllowedIPs, Keepalive: in.Keepalive, LatencyCheck: lc, Created: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
// With a link, the keys are made when the link is opened.
|
// With a link, the keys are made when the link is opened.
|
||||||
var priv string
|
var priv string
|
||||||
@@ -735,6 +768,18 @@ func (a *App) patchPeer(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
changed = append(changed, "keepalive")
|
changed = append(changed, "keepalive")
|
||||||
}
|
}
|
||||||
|
if raw, ok := m["latencyCheck"]; ok {
|
||||||
|
var v string
|
||||||
|
if err := json.Unmarshal(raw, &v); err != nil {
|
||||||
|
return badRequest("latencyCheck: %v", err)
|
||||||
|
}
|
||||||
|
lc, err := latencyMode(v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p.LatencyCheck = lc
|
||||||
|
changed = append(changed, "latencyCheck")
|
||||||
|
}
|
||||||
p.Name = strings.TrimSpace(p.Name)
|
p.Name = strings.TrimSpace(p.Name)
|
||||||
p.Note = strings.TrimSpace(p.Note)
|
p.Note = strings.TrimSpace(p.Note)
|
||||||
name = p.Name
|
name = p.Name
|
||||||
|
|||||||
@@ -181,6 +181,20 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-d
|
|||||||
.chart .grp.on span.total { background: var(--down-strong); }
|
.chart .grp.on span.total { background: var(--down-strong); }
|
||||||
.xaxis { display: flex; justify-content: space-between; margin: 8px 0 0 56px; font-size: 11px; color: var(--axis-ink); }
|
.xaxis { display: flex; justify-content: space-between; margin: 8px 0 0 56px; font-size: 11px; color: var(--axis-ink); }
|
||||||
.chart.loading { opacity: .5; }
|
.chart.loading { opacity: .5; }
|
||||||
|
.chart .plot { position: absolute; left: 56px; right: 0; top: 0; bottom: 1px; }
|
||||||
|
.chart .plot svg { width: 100%; height: 100%; display: block; overflow: visible; }
|
||||||
|
.chart .band, .key.band { fill: var(--down); background: var(--down); opacity: .2; }
|
||||||
|
.chart .med { fill: none; stroke: var(--down); stroke-width: 1.75; stroke-linejoin: round; stroke-linecap: round; vector-effect: non-scaling-stroke; }
|
||||||
|
.chart .cursor { stroke: var(--axis); stroke-width: 1; vector-effect: non-scaling-stroke; }
|
||||||
|
|
||||||
|
/* latency in the peer list */
|
||||||
|
.lat { display: inline-flex; vertical-align: middle; align-items: center; justify-content: flex-end; gap: 8px; }
|
||||||
|
.lat .mono { min-width: 6ch; text-align: right; }
|
||||||
|
.latnote { font-size: 12px; color: var(--ink-3); }
|
||||||
|
.lat.stale, .latnote.stale { opacity: .5; }
|
||||||
|
.spark { width: 56px; height: 18px; display: block; flex: none; }
|
||||||
|
.spark polyline { fill: none; stroke: var(--down); stroke-width: 1.5; stroke-linejoin: round; stroke-linecap: round; }
|
||||||
|
.spark circle { fill: var(--down); }
|
||||||
|
|
||||||
/* config / log blocks */
|
/* config / log blocks */
|
||||||
pre.code, pre.log { margin: 0; padding: 14px; background: var(--ink); color: #e6e6e1; border-radius: 10px; font-family: var(--mono); font-size: 12px; line-height: 1.6; overflow-x: auto; white-space: pre; }
|
pre.code, pre.log { margin: 0; padding: 14px; background: var(--ink); color: #e6e6e1; border-radius: 10px; font-family: var(--mono); font-size: 12px; line-height: 1.6; overflow-x: auto; white-space: pre; }
|
||||||
|
|||||||
@@ -148,6 +148,54 @@
|
|||||||
|
|
||||||
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);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
for (const [k, v] of Object.entries(attrs || {})) el.setAttribute(k, v);
|
||||||
|
add(el, kids);
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtMs = (ms) => (ms < 10 ? ms.toFixed(1) : String(Math.round(ms))) + ' ms';
|
||||||
|
|
||||||
|
// A latency value is stale when the server stopped pinging, e.g. because
|
||||||
|
// the device went idle; it is then shown greyed out.
|
||||||
|
const latStale = (l) => Date.now() - Date.parse(l.at) > 3 * 30000;
|
||||||
|
|
||||||
|
// latState describes a peer's latency for the lists: null when there is
|
||||||
|
// nothing to show.
|
||||||
|
function latState(p) {
|
||||||
|
if (!p.enabled || !p.publicKey) return null;
|
||||||
|
if (p.latencyCheck === 'off') return { note: 'Check off', title: 'The latency check is off for this peer' };
|
||||||
|
const l = p.stats.latency;
|
||||||
|
const idle = p.latencyCheck === 'active' ? 'Pinged only while the device sends traffic' : 'Not measured yet';
|
||||||
|
if (!l) return { note: '–', title: idle };
|
||||||
|
const stale = latStale(l);
|
||||||
|
const when = stale ? ' · measured ' + ago(l.at) : '';
|
||||||
|
if (l.ms == null) return { note: 'No ping reply', stale, title: 'The device does not answer ping. Windows blocks it in its firewall by default.' + when };
|
||||||
|
return { ms: l.ms, spark: l.spark, stale, title: 'Median of the last 5 minutes · ' + fmtMs(l.min) + '–' + fmtMs(l.max) + ' · ' + l.loss + ' % loss' + when };
|
||||||
|
}
|
||||||
|
|
||||||
|
// sparkline draws the medians of the last hour; gaps are skipped.
|
||||||
|
function sparkline(vals) {
|
||||||
|
const pts = (vals || []).map((v, i) => [i, v]).filter(([, v]) => v != null);
|
||||||
|
const s = svg('svg', { class: 'spark', viewBox: '0 0 56 18', 'aria-hidden': 'true' });
|
||||||
|
if (pts.length < 2) return s;
|
||||||
|
const lo = Math.min(...pts.map(([, v]) => v)), hi = Math.max(...pts.map(([, v]) => v));
|
||||||
|
const span = Math.max(hi - lo, hi * 0.2, 1);
|
||||||
|
const xy = ([i, v]) => [(i / (vals.length - 1) * 54 + 1).toFixed(1), (16 - (v - lo) / span * 14).toFixed(1)];
|
||||||
|
const [ex, ey] = xy(pts[pts.length - 1]);
|
||||||
|
s.append(svg('polyline', { points: pts.map((p) => xy(p).join(',')).join(' ') }), svg('circle', { cx: ex, cy: ey, r: 2 }));
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function latCell(p) {
|
||||||
|
const st = latState(p);
|
||||||
|
if (!st) return h('td', { class: 'num muted' }, '–');
|
||||||
|
if (st.ms == null) return h('td', { class: 'num' }, h('span', { class: st.stale ? 'latnote stale' : 'latnote', title: st.title }, st.note));
|
||||||
|
return h('td', { class: 'num' }, h('span', { class: st.stale ? 'lat stale' : 'lat', title: st.title }, sparkline(st.spark), h('span', { class: 'mono' }, fmtMs(st.ms))));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- API ----------
|
// ---------- API ----------
|
||||||
|
|
||||||
async function api(method, path, body) {
|
async function api(method, path, body) {
|
||||||
@@ -346,6 +394,63 @@
|
|||||||
h('span', null, range === '24h' ? 'now' : pointLabel(points[points.length - 1].t, range))));
|
h('span', null, range === '24h' ? 'now' : pointLabel(points[points.length - 1].t, range))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// latencyChart draws the median as a line over a min–max band, one point
|
||||||
|
// per 5 minutes; steps without replies leave a gap. Hover shows the values.
|
||||||
|
function latencyChart(points) {
|
||||||
|
const ok = (p) => p.sent > p.lost;
|
||||||
|
const peak = Math.max(0, ...points.filter(ok).map((p) => p.max));
|
||||||
|
const top = peak > 0 ? niceTop(peak) : 100;
|
||||||
|
const n = points.length, W = 1000, H = 100;
|
||||||
|
const x = (i) => (i + 0.5) / n * W, y = (v) => (H - v / top * H).toFixed(2);
|
||||||
|
const segs = [];
|
||||||
|
points.forEach((p, i) => {
|
||||||
|
if (!ok(p)) return;
|
||||||
|
const last = segs[segs.length - 1];
|
||||||
|
if (last && last[last.length - 1] === i - 1) last.push(i); else segs.push([i]);
|
||||||
|
});
|
||||||
|
const half = W / n * 0.4;
|
||||||
|
const shapes = segs.flatMap((seg) => {
|
||||||
|
// A lone point gets a short flat stretch so it stays visible.
|
||||||
|
const xs = seg.length > 1 ? seg.map(x) : [x(seg[0]) - half, x(seg[0]) + half];
|
||||||
|
const at = (k) => points[seg[Math.min(k, seg.length - 1)]];
|
||||||
|
const upper = xs.map((xv, k) => xv.toFixed(1) + ',' + y(at(k).max));
|
||||||
|
const lower = xs.map((xv, k) => xv.toFixed(1) + ',' + y(at(k).min)).reverse();
|
||||||
|
return [
|
||||||
|
svg('polygon', { class: 'band', points: upper.concat(lower).join(' ') }),
|
||||||
|
svg('polyline', { class: 'med', points: xs.map((xv, k) => xv.toFixed(1) + ',' + y(at(k).med)).join(' ') }),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
const cursor = svg('line', { class: 'cursor', x1: 0, x2: 0, y1: 0, y2: H, visibility: 'hidden' });
|
||||||
|
const plot = svg('svg', { viewBox: '0 0 ' + W + ' ' + H, preserveAspectRatio: 'none', 'aria-hidden': 'true' }, shapes, cursor);
|
||||||
|
const label = (p) => {
|
||||||
|
const d = new Date(p.t * 1000);
|
||||||
|
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
const withData = points.filter(ok);
|
||||||
|
const idle = () => withData.length
|
||||||
|
? ['Median over 24 hours ', h('strong', null, fmtMs(withData.map((p) => p.med).sort((a, b) => a - b)[withData.length >> 1])), ' · hover the chart to see a time.']
|
||||||
|
: ['No measurements in the last 24 hours.'];
|
||||||
|
const readout = h('div', { class: 'readout' }, idle());
|
||||||
|
const wrapEl = h('div', { class: 'plot', role: 'img', 'aria-label': 'Latency over the last 24 hours',
|
||||||
|
onMousemove: (e) => {
|
||||||
|
const r = wrapEl.getBoundingClientRect();
|
||||||
|
const i = Math.max(0, Math.min(n - 1, Math.floor((e.clientX - r.left) / r.width * n)));
|
||||||
|
const p = points[i];
|
||||||
|
cursor.setAttribute('x1', x(i)); cursor.setAttribute('x2', x(i)); cursor.setAttribute('visibility', 'visible');
|
||||||
|
readout.replaceChildren(...(ok(p)
|
||||||
|
? [label(p), ' · median ', h('strong', null, fmtMs(p.med)), ' · ' + fmtMs(p.min) + '–' + fmtMs(p.max) + ' · ' + Math.round(p.lost * 100 / p.sent) + ' % loss']
|
||||||
|
: [label(p), p.sent ? ' · no reply' : ' · not measured']));
|
||||||
|
},
|
||||||
|
onMouseleave: () => { cursor.setAttribute('visibility', 'hidden'); readout.replaceChildren(...idle()); } }, plot);
|
||||||
|
return h('div', null,
|
||||||
|
readout,
|
||||||
|
h('div', { class: 'chart small' },
|
||||||
|
h('div', { class: 'gl top' }), h('div', { class: 'gl mid' }), h('div', { class: 'gl base' }),
|
||||||
|
h('div', { class: 'yl top' }, fmtMs(top)), h('div', { class: 'yl mid' }, fmtMs(top / 2)),
|
||||||
|
wrapEl),
|
||||||
|
h('div', { class: 'xaxis' }, h('span', null, '24 h ago'), h('span', null, '12 h ago'), h('span', null, 'now')));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- shell, router ----------
|
// ---------- shell, router ----------
|
||||||
|
|
||||||
const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/settings', 'settings', 'Settings']];
|
const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/settings', 'settings', 'Settings']];
|
||||||
@@ -574,6 +679,7 @@
|
|||||||
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 || '–',
|
||||||
p.stats.location && p.stats.location.country ? h('span', { class: 'cc', title: fmtLocation(p.stats.location) }, p.stats.location.country) : null),
|
p.stats.location && p.stats.location.country ? h('span', { class: 'cc', title: fmtLocation(p.stats.location) }, p.stats.location.country) : null),
|
||||||
|
latCell(p),
|
||||||
h('td', { class: 'num' }, fmtBytes(p.stats.down30d)),
|
h('td', { class: 'num' }, fmtBytes(p.stats.down30d)),
|
||||||
h('td', { class: 'num' }, fmtBytes(p.stats.up30d)),
|
h('td', { class: 'num' }, fmtBytes(p.stats.up30d)),
|
||||||
h('td', null, h('input', { type: 'checkbox', class: 'sw', checked: p.enabled, 'aria-label': (p.enabled ? 'Disable ' : 'Enable ') + p.name, onChange: (e) => toggle(p, e.target.checked) })),
|
h('td', null, h('input', { type: 'checkbox', class: 'sw', checked: p.enabled, 'aria-label': (p.enabled ? 'Disable ' : 'Enable ') + p.name, onChange: (e) => toggle(p, e.target.checked) })),
|
||||||
@@ -593,9 +699,9 @@
|
|||||||
pills),
|
pills),
|
||||||
h('section', { class: 'card flush' }, h('div', { class: 'tbl' }, h('table', null,
|
h('section', { class: 'card flush' }, h('div', { class: 'tbl' }, h('table', null,
|
||||||
h('thead', null, h('tr', null, ['Name', 'Address', 'Status', 'Endpoint'].map((t) => h('th', null, t)),
|
h('thead', null, h('tr', null, ['Name', 'Address', 'Status', 'Endpoint'].map((t) => h('th', null, t)),
|
||||||
h('th', { class: 'num' }, 'Download, 30 d'), h('th', { class: 'num' }, 'Upload, 30 d'), h('th', null, 'Enabled'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
|
h('th', { class: 'num' }, 'Latency'), h('th', { class: 'num' }, 'Download, 30 d'), h('th', { class: 'num' }, 'Upload, 30 d'), h('th', null, 'Enabled'), h('th', null, h('span', { class: 'sr' }, 'Actions')))),
|
||||||
tbody), empty)),
|
tbody), empty)),
|
||||||
h('p', { class: 'muted', style: { margin: '0', fontSize: '13px' } }, 'Online means a handshake in the last 3 minutes. Download and Upload are measured from the peer\'s side. Changes apply live without disconnecting other peers.'));
|
h('p', { class: 'muted', style: { margin: '0', fontSize: '13px' } }, 'Online means a handshake in the last 3 minutes. Latency is the round trip from the server through the tunnel to the device and back, median of the last 5 minutes; turn it on in a peer\'s settings. Download and Upload are measured from the peer\'s side. Changes apply live without disconnecting other peers.'));
|
||||||
every(15000, async () => { try { data = await api('GET', '/peers'); drawRows(); } catch { /* keep last */ } });
|
every(15000, async () => { try { data = await api('GET', '/peers'); drawRows(); } catch { /* keep last */ } });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -745,6 +851,21 @@
|
|||||||
const totals = h('div', { class: 'legend-row' });
|
const totals = h('div', { class: 'legend-row' });
|
||||||
const pills = h('div', { class: 'pills', role: 'group', 'aria-label': 'Time range' });
|
const pills = h('div', { class: 'pills', role: 'group', 'aria-label': 'Time range' });
|
||||||
|
|
||||||
|
const showLat = p.latencyCheck !== 'off' || p.stats.latency;
|
||||||
|
const latBox = h('div');
|
||||||
|
async function drawLatency() {
|
||||||
|
if (!showLat) return;
|
||||||
|
try { latBox.replaceChildren(latencyChart((await api('GET', '/peers/' + id + '/latency')).points)); } catch (e) { latBox.replaceChildren(h('p', { class: 'err-text' }, e.message)); }
|
||||||
|
}
|
||||||
|
const latText = () => {
|
||||||
|
const l = p.stats.latency;
|
||||||
|
if (p.latencyCheck === 'off' && !l) return h('span', { class: 'muted' }, 'Check off');
|
||||||
|
if (!l) return h('span', { class: 'muted' }, p.latencyCheck === 'active' ? 'Not measured yet · pinged only while the device sends traffic' : 'Not measured yet');
|
||||||
|
const when = latStale(l) || p.latencyCheck === 'off' ? h('span', { class: 'muted' }, ' · measured ' + ago(l.at)) : null;
|
||||||
|
if (l.ms == null) return [h('span', null, 'No ping reply'), when];
|
||||||
|
return [h('span', { class: 'mono' }, fmtMs(l.ms)), h('span', { class: 'muted' }, ' median · ' + fmtMs(l.min) + '–' + fmtMs(l.max) + ' · ' + l.loss + ' % loss'), when];
|
||||||
|
};
|
||||||
|
|
||||||
async function drawTraffic() {
|
async function drawTraffic() {
|
||||||
pills.replaceChildren(...[['24h', '24 h'], ['7d', '7 days'], ['30d', '30 days']].map(([k, t]) =>
|
pills.replaceChildren(...[['24h', '24 h'], ['7d', '7 days'], ['30d', '30 days']].map(([k, t]) =>
|
||||||
h('button', { type: 'button', class: range === k ? 'pill on' : 'pill', 'aria-pressed': String(range === k), onClick: () => { range = k; drawTraffic(); } }, t)));
|
h('button', { type: 'button', class: range === k ? 'pill on' : 'pill', 'aria-pressed': String(range === k), onClick: () => { range = k; drawTraffic(); } }, t)));
|
||||||
@@ -829,11 +950,19 @@
|
|||||||
const dns = choice({ id: 'dns', label: 'DNS', ...ch.dns, placeholder: '9.9.9.9' });
|
const dns = choice({ id: 'dns', label: 'DNS', ...ch.dns, placeholder: '9.9.9.9' });
|
||||||
const allowed = choice({ id: 'ai', label: 'Client AllowedIPs', ...ch.allowed, hint: 'Used in the client config', placeholder: '0.0.0.0/0, ::/0' });
|
const allowed = choice({ id: 'ai', label: 'Client AllowedIPs', ...ch.allowed, hint: 'Used in the client config', placeholder: '0.0.0.0/0, ::/0' });
|
||||||
const ka = choice({ id: 'ka', label: 'Persistent keepalive', ...ch.ka, placeholder: 'Seconds' });
|
const ka = choice({ id: 'ka', label: 'Persistent keepalive', ...ch.ka, placeholder: 'Seconds' });
|
||||||
|
const LAT_HINTS = {
|
||||||
|
off: 'The server never pings this peer.',
|
||||||
|
active: 'Pings every 30 s while the device sends traffic. An idle device is left alone.',
|
||||||
|
always: 'Pings every 30 s, even when idle. This keeps the tunnel up, so the peer always shows as Online. Best for servers and routers.',
|
||||||
|
};
|
||||||
|
const latHint = h('span', { class: 'hint' }, LAT_HINTS[p.latencyCheck]);
|
||||||
|
const lc = h('select', { id: 'lc', onChange: () => { latHint.textContent = LAT_HINTS[lc.value]; } },
|
||||||
|
[['off', 'Off'], ['active', 'While the device is active'], ['always', 'Always']].map(([v, t]) => h('option', { value: v, selected: v === p.latencyCheck }, t)));
|
||||||
const save = async (e) => {
|
const save = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
err.textContent = '';
|
err.textContent = '';
|
||||||
try {
|
try {
|
||||||
const body = { name: name.value, note: note.value, ipv4: ip.value.trim(), ...overrides(dns, allowed, ka, srv) };
|
const body = { name: name.value, note: note.value, ipv4: ip.value.trim(), latencyCheck: lc.value, ...overrides(dns, allowed, ka, srv) };
|
||||||
const res = await api('PATCH', '/peers/' + id, body);
|
const res = await api('PATCH', '/peers/' + id, body);
|
||||||
applied(res, 'Saved');
|
applied(res, 'Saved');
|
||||||
render();
|
render();
|
||||||
@@ -856,6 +985,13 @@
|
|||||||
h('div', { class: 'cardhead' }, h('div', null, h('h2', { id: 'traffic' }, 'Traffic'), totals), pills),
|
h('div', { class: 'cardhead' }, h('div', null, h('h2', { id: 'traffic' }, 'Traffic'), totals), pills),
|
||||||
traffic),
|
traffic),
|
||||||
|
|
||||||
|
showLat ? h('section', { class: 'card', 'aria-labelledby': 'lat' },
|
||||||
|
h('div', { class: 'cardhead' }, h('h2', { id: 'lat' }, 'Latency · last 24 hours'),
|
||||||
|
h('div', { class: 'legend-row', style: { marginTop: '0' } },
|
||||||
|
h('span', null, h('span', { class: 'key down' }), 'Median'),
|
||||||
|
h('span', null, h('span', { class: 'key band' }), 'Min–max'))),
|
||||||
|
latBox) : null,
|
||||||
|
|
||||||
h('div', { class: 'cols' },
|
h('div', { class: 'cols' },
|
||||||
h('section', { class: 'card' },
|
h('section', { class: 'card' },
|
||||||
h('h2', null, 'Connection'),
|
h('h2', null, 'Connection'),
|
||||||
@@ -864,6 +1000,7 @@
|
|||||||
h('dt', null, 'Endpoint'), h('dd', { class: 'mono' }, p.stats.endpoint || '–'),
|
h('dt', null, 'Endpoint'), h('dd', { class: 'mono' }, p.stats.endpoint || '–'),
|
||||||
h('dt', null, 'Location'), h('dd', null, fmtLocation(p.stats.location) || '–'),
|
h('dt', null, 'Location'), h('dd', null, fmtLocation(p.stats.location) || '–'),
|
||||||
h('dt', null, 'Latest handshake'), h('dd', null, ago(p.stats.lastHandshake)),
|
h('dt', null, 'Latest handshake'), h('dd', null, ago(p.stats.lastHandshake)),
|
||||||
|
h('dt', null, 'Latency'), h('dd', null, latText()),
|
||||||
h('dt', null, 'Public key'), h('dd', { class: 'mono' }, p.publicKey || '–'),
|
h('dt', null, 'Public key'), h('dd', { class: 'mono' }, p.publicKey || '–'),
|
||||||
h('dt', null, 'Preshared key'), h('dd', null, p.hasPresharedKey ? 'Set' : 'None'),
|
h('dt', null, 'Preshared key'), h('dd', null, p.hasPresharedKey ? 'Set' : 'None'),
|
||||||
h('dt', null, 'All-time traffic'), h('dd', null, 'Download ' + fmtBytes(p.stats.downTotal) + ' · Upload ' + fmtBytes(p.stats.upTotal)))),
|
h('dt', null, 'All-time traffic'), h('dd', null, 'Download ' + fmtBytes(p.stats.downTotal) + ' · Upload ' + fmtBytes(p.stats.upTotal)))),
|
||||||
@@ -894,17 +1031,18 @@
|
|||||||
|
|
||||||
h('form', { class: 'card', onSubmit: save },
|
h('form', { class: 'card', onSubmit: save },
|
||||||
h('h2', null, 'Settings'),
|
h('h2', null, 'Settings'),
|
||||||
h('p', { class: 'lead' }, 'Name and address changes apply immediately. DNS, AllowedIPs and keepalive are part of the client config: they take effect after the config is issued again.'),
|
h('p', { class: 'lead' }, 'Name, address and latency check changes apply immediately. DNS, AllowedIPs and keepalive are part of the client config: they take effect after the config is issued again.'),
|
||||||
h('div', { class: 'grid' },
|
h('div', { class: 'grid' },
|
||||||
h('div', { class: 'field' }, h('label', { htmlFor: 'n' }, 'Name'), name, h('span', { class: 'hint' }, 'Letters, numbers, . _ @ - · max 32')),
|
h('div', { class: 'field' }, h('label', { htmlFor: 'n' }, 'Name'), name, h('span', { class: 'hint' }, 'Letters, numbers, . _ @ - · max 32')),
|
||||||
h('div', { class: 'field' }, h('label', { htmlFor: 'no' }, 'Note'), note),
|
h('div', { class: 'field' }, h('label', { htmlFor: 'no' }, 'Note'), note),
|
||||||
h('div', { class: 'field' }, h('label', { htmlFor: 'ip' }, 'IPv4 address'), ip, h('span', { class: 'hint' }, 'Changing it requires a new client config')),
|
h('div', { class: 'field' }, h('label', { htmlFor: 'ip' }, 'IPv4 address'), ip, h('span', { class: 'hint' }, 'Changing it requires a new client config')),
|
||||||
ka.el, allowed.el, dns.el),
|
ka.el, allowed.el, dns.el,
|
||||||
|
h('div', { class: 'field' }, h('label', { htmlFor: 'lc' }, 'Latency check'), lc, latHint)),
|
||||||
err,
|
err,
|
||||||
h('div', { class: 'formfoot' },
|
h('div', { class: 'formfoot' },
|
||||||
h('button', { type: 'button', class: 'btn', onClick: render }, 'Cancel'),
|
h('button', { type: 'button', class: 'btn', onClick: render }, 'Cancel'),
|
||||||
h('button', { type: 'submit', class: 'btn primary' }, 'Save changes'))));
|
h('button', { type: 'submit', class: 'btn primary' }, 'Save changes'))));
|
||||||
await drawTraffic();
|
await Promise.all([drawTraffic(), drawLatency()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- server ----------
|
// ---------- server ----------
|
||||||
|
|||||||
@@ -116,6 +116,9 @@ type Peer struct {
|
|||||||
DNS []string `json:"dns,omitempty"` // nil = server default
|
DNS []string `json:"dns,omitempty"` // nil = server default
|
||||||
AllowedIPs []string `json:"allowedIPs,omitempty"` // nil = server default
|
AllowedIPs []string `json:"allowedIPs,omitempty"` // nil = server default
|
||||||
Keepalive *int `json:"keepalive,omitempty"` // nil = server default
|
Keepalive *int `json:"keepalive,omitempty"` // nil = server default
|
||||||
|
// LatencyCheck says when the server pings the peer through the tunnel:
|
||||||
|
// "" (off), "active" (while the device sends traffic) or "always".
|
||||||
|
LatencyCheck string `json:"latencyCheck,omitempty"`
|
||||||
Created time.Time `json:"created"`
|
Created time.Time `json:"created"`
|
||||||
ConfigIssued *time.Time `json:"configIssued,omitempty"`
|
ConfigIssued *time.Time `json:"configIssued,omitempty"`
|
||||||
// Setup is a pending one-time setup link. A peer created with a link has
|
// Setup is a pending one-time setup link. A peer created with a link has
|
||||||
@@ -366,6 +369,9 @@ func (c *Config) validate() error {
|
|||||||
if p.Keepalive != nil && (*p.Keepalive < 0 || *p.Keepalive > 3600) {
|
if p.Keepalive != nil && (*p.Keepalive < 0 || *p.Keepalive > 3600) {
|
||||||
return fmt.Errorf("peer %q: keepalive must be 0–3600 seconds", p.Name)
|
return fmt.Errorf("peer %q: keepalive must be 0–3600 seconds", p.Name)
|
||||||
}
|
}
|
||||||
|
if !validLatencyCheck(p.LatencyCheck) {
|
||||||
|
return fmt.Errorf("peer %q: latency check must be off, active or always", p.Name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ go 1.27.1
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/google/nftables v0.3.0
|
github.com/google/nftables v0.3.0
|
||||||
|
github.com/oschwald/maxminddb-golang v1.13.1
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
github.com/vishvananda/netlink v1.3.1
|
github.com/vishvananda/netlink v1.3.1
|
||||||
golang.org/x/crypto v0.57.0
|
golang.org/x/crypto v0.57.0
|
||||||
|
golang.org/x/net v0.58.0
|
||||||
golang.org/x/sys v0.48.0
|
golang.org/x/sys v0.48.0
|
||||||
golang.org/x/term v0.46.0
|
golang.org/x/term v0.46.0
|
||||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
|
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10
|
||||||
@@ -17,9 +19,7 @@ require (
|
|||||||
github.com/mdlayher/genetlink v1.3.2 // indirect
|
github.com/mdlayher/genetlink v1.3.2 // indirect
|
||||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
|
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
|
||||||
github.com/mdlayher/socket v0.5.1 // indirect
|
github.com/mdlayher/socket v0.5.1 // indirect
|
||||||
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
|
|
||||||
github.com/vishvananda/netns v0.0.5 // indirect
|
github.com/vishvananda/netns v0.0.5 // indirect
|
||||||
golang.org/x/net v0.58.0 // indirect
|
|
||||||
golang.org/x/sync v0.23.0 // indirect
|
golang.org/x/sync v0.23.0 // indirect
|
||||||
golang.org/x/text v0.42.0 // indirect
|
golang.org/x/text v0.42.0 // indirect
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
|
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg=
|
github.com/google/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg=
|
||||||
@@ -12,8 +14,12 @@ github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE9
|
|||||||
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
|
github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc=
|
||||||
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
|
||||||
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||||
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
||||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||||
@@ -36,3 +42,5 @@ golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uI
|
|||||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
|
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
|
||||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
|
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10 h1:3GDAcqdIg1ozBNLgPy4SLT84nfcBjr6rhGtXYtrkWLU=
|
||||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
|
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20241231184526-a9ab2273dd10/go.mod h1:T97yPqesLiNrOYxkwmhMI0ZIlJDm+p0PMR8eRVeR5tQ=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/netip"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -30,6 +31,9 @@ type Kernel interface {
|
|||||||
Sample(iface string) ([]PeerSample, error)
|
Sample(iface string) ([]PeerSample, error)
|
||||||
Checks(c *Config) []Check
|
Checks(c *Config) []Check
|
||||||
Uplink(c *Config, v6 bool) string
|
Uplink(c *Config, v6 bool) string
|
||||||
|
// Ping sends one echo request to each address and returns the round-trip
|
||||||
|
// times of the replies that came within timeout.
|
||||||
|
Ping(dsts []netip.Addr, timeout time.Duration) (map[netip.Addr]time.Duration, error)
|
||||||
Down(c *Config) error
|
Down(c *Config) error
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
|
"net/netip"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -89,3 +90,18 @@ func (k *simKernel) Uplink(c *Config, v6 bool) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (k *simKernel) Down(*Config) error { return nil }
|
func (k *simKernel) Down(*Config) error { return nil }
|
||||||
|
|
||||||
|
// Ping invents round trips from the address, so each peer keeps its own
|
||||||
|
// typical latency. Every seventh address never answers, like a Windows PC.
|
||||||
|
func (k *simKernel) Ping(dsts []netip.Addr, _ time.Duration) (map[netip.Addr]time.Duration, error) {
|
||||||
|
out := map[netip.Addr]time.Duration{}
|
||||||
|
for _, d := range dsts {
|
||||||
|
last := int(d.As4()[3])
|
||||||
|
if last%7 == 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base := 8 + last*37%180
|
||||||
|
out[d] = time.Duration(base*1000+rand.IntN(base*300+1)) * time.Microsecond
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
+305
@@ -0,0 +1,305 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Latency is measured by pinging a peer's tunnel address from the server:
|
||||||
|
// WireGuard itself reports no round-trip time. A ping to an idle device
|
||||||
|
// wakes it and starts new handshakes, which would keep it "online" for good,
|
||||||
|
// so the "active" mode pings only while the device sends traffic of its own.
|
||||||
|
|
||||||
|
const (
|
||||||
|
latencyOff = ""
|
||||||
|
latencyActive = "active"
|
||||||
|
latencyAlways = "always"
|
||||||
|
|
||||||
|
pingInterval = 30 * time.Second
|
||||||
|
pingTimeout = 2 * time.Second
|
||||||
|
latencyStep = 5 * time.Minute // one point of the latency history
|
||||||
|
latencyKeep = 24 * time.Hour
|
||||||
|
latencyWindow = 5 * time.Minute // the current value is the median of this window
|
||||||
|
activeWindow = 2 * time.Minute
|
||||||
|
// activeRxBytes is what a peer must send in one sample interval to count
|
||||||
|
// as active. Ping replies (~128 bytes) and keepalives stay well below it.
|
||||||
|
activeRxBytes = 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
func validLatencyCheck(m string) bool {
|
||||||
|
return m == latencyOff || m == latencyActive || m == latencyAlways
|
||||||
|
}
|
||||||
|
|
||||||
|
// latBucket is one step of a peer's latency history, in milliseconds. The
|
||||||
|
// single round trips are kept only while the step is open; a closed step
|
||||||
|
// keeps its summary.
|
||||||
|
type latBucket struct {
|
||||||
|
T int64 `json:"t"`
|
||||||
|
Sent int `json:"sent"`
|
||||||
|
Lost int `json:"lost"`
|
||||||
|
Min float64 `json:"min,omitempty"`
|
||||||
|
Med float64 `json:"med,omitempty"`
|
||||||
|
Max float64 `json:"max,omitempty"`
|
||||||
|
RTTs []float64 `json:"rtts,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// summary returns the bucket with Min, Med and Max filled in and no RTTs.
|
||||||
|
func (b latBucket) summary() latBucket {
|
||||||
|
if len(b.RTTs) > 0 {
|
||||||
|
b.Min, b.Med, b.Max = spread(b.RTTs)
|
||||||
|
}
|
||||||
|
b.RTTs = nil
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// spread returns the minimum, median and maximum of v (not empty).
|
||||||
|
func spread(v []float64) (lo, med, hi float64) {
|
||||||
|
s := slices.Clone(v)
|
||||||
|
slices.Sort(s)
|
||||||
|
n := len(s)
|
||||||
|
med = s[n/2]
|
||||||
|
if n%2 == 0 {
|
||||||
|
med = (s[n/2-1] + s[n/2]) / 2
|
||||||
|
}
|
||||||
|
return s[0], med, s[n-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
type latSample struct {
|
||||||
|
t time.Time
|
||||||
|
ms float64
|
||||||
|
ok bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// latLive is the part of the latency state that is not saved.
|
||||||
|
type latLive struct {
|
||||||
|
recent []latSample // pings of the last latencyWindow
|
||||||
|
lastActive time.Time // last sample interval with traffic from the peer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stats) liveFor(id string) *latLive {
|
||||||
|
l := s.live[id]
|
||||||
|
if l == nil {
|
||||||
|
l = &latLive{}
|
||||||
|
s.live[id] = l
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// record adds one ping result. s.mu must be held.
|
||||||
|
func (s *Stats) record(id string, now time.Time, rtt time.Duration, ok bool) {
|
||||||
|
ps := s.data.Peers[id]
|
||||||
|
if ps == nil {
|
||||||
|
ps = &peerStats{}
|
||||||
|
s.data.Peers[id] = ps
|
||||||
|
}
|
||||||
|
ms := float64(rtt.Microseconds()) / 1000
|
||||||
|
t := now.Truncate(latencyStep).Unix()
|
||||||
|
if n := len(ps.Latency); n == 0 || ps.Latency[n-1].T != t {
|
||||||
|
if n > 0 {
|
||||||
|
ps.Latency[n-1] = ps.Latency[n-1].summary()
|
||||||
|
}
|
||||||
|
ps.Latency = append(ps.Latency, latBucket{T: t})
|
||||||
|
}
|
||||||
|
b := &ps.Latency[len(ps.Latency)-1]
|
||||||
|
b.Sent++
|
||||||
|
if ok {
|
||||||
|
b.RTTs = append(b.RTTs, ms)
|
||||||
|
} else {
|
||||||
|
b.Lost++
|
||||||
|
}
|
||||||
|
l := s.liveFor(id)
|
||||||
|
i := 0
|
||||||
|
for i < len(l.recent) && now.Sub(l.recent[i].t) >= latencyWindow {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
l.recent = append(l.recent[i:], latSample{t: now, ms: ms, ok: ok})
|
||||||
|
s.dirty = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// pingTargets returns the tunnel addresses to ping now, by peer ID.
|
||||||
|
func (s *Stats) pingTargets(c *Config, now time.Time) map[netip.Addr]string {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
out := map[netip.Addr]string{}
|
||||||
|
for _, p := range c.Peers {
|
||||||
|
if !p.Enabled || !p.hasKey() || p.LatencyCheck == latencyOff {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ps := s.data.Peers[p.ID]
|
||||||
|
if ps == nil || ps.LastHandshake.IsZero() {
|
||||||
|
continue // never connected: the kernel has no endpoint to send to
|
||||||
|
}
|
||||||
|
if p.LatencyCheck == latencyActive && now.Sub(s.liveFor(p.ID).lastActive) > activeWindow {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a, err := netip.ParseAddr(p.IPv4); err == nil {
|
||||||
|
out[a] = p.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stats) pingRound() {
|
||||||
|
targets := s.pingTargets(s.store.Get(), time.Now())
|
||||||
|
if len(targets) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dsts := make([]netip.Addr, 0, len(targets))
|
||||||
|
for a := range targets {
|
||||||
|
dsts = append(dsts, a)
|
||||||
|
}
|
||||||
|
start := time.Now()
|
||||||
|
rtts, err := s.kernel.Ping(dsts, pingTimeout)
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if (err == nil) != (s.pingErr == nil) {
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("latency check failed", "err", err)
|
||||||
|
} else {
|
||||||
|
slog.Info("latency check works again")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.pingErr = err
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for a, id := range targets {
|
||||||
|
rtt, ok := rtts[a]
|
||||||
|
s.record(id, start, rtt, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunPings runs the latency checks until stop is closed.
|
||||||
|
func (s *Stats) RunPings(stop <-chan struct{}) {
|
||||||
|
t := time.NewTicker(pingInterval)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
s.pingRound()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PingCheck is the health line for the latency check; ok is false when no
|
||||||
|
// peer has the check turned on.
|
||||||
|
func (s *Stats) PingCheck(c *Config) (Check, bool) {
|
||||||
|
on := slices.ContainsFunc(c.Peers, func(p Peer) bool { return p.Enabled && p.LatencyCheck != latencyOff })
|
||||||
|
if !on {
|
||||||
|
return Check{}, false
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.pingErr != nil {
|
||||||
|
return Check{Name: "Latency check", OK: false, Detail: s.pingErr.Error()}, true
|
||||||
|
}
|
||||||
|
return Check{Name: "Latency check", OK: true, Detail: "ping through the tunnel works"}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatencyView is a peer's current latency, in milliseconds.
|
||||||
|
type LatencyView struct {
|
||||||
|
MS *float64 `json:"ms"` // median; null = no reply
|
||||||
|
Min float64 `json:"min"`
|
||||||
|
Max float64 `json:"max"`
|
||||||
|
Loss int `json:"loss"` // percent of pings without a reply
|
||||||
|
At time.Time `json:"at"` // newest ping
|
||||||
|
Spark []*float64 `json:"spark"` // medians of the last hour, oldest first; null = no reply
|
||||||
|
}
|
||||||
|
|
||||||
|
// latencyView returns the current latency, or nil when the peer was never
|
||||||
|
// pinged. s.mu must be held.
|
||||||
|
func (s *Stats) latencyView(id string, ps *peerStats, now time.Time) *LatencyView {
|
||||||
|
if len(ps.Latency) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := &LatencyView{}
|
||||||
|
var rtts []float64
|
||||||
|
var sent, lost int
|
||||||
|
if l := s.live[id]; l != nil && len(l.recent) > 0 {
|
||||||
|
v.At = l.recent[len(l.recent)-1].t
|
||||||
|
for _, x := range l.recent {
|
||||||
|
sent++
|
||||||
|
if x.ok {
|
||||||
|
rtts = append(rtts, x.ms)
|
||||||
|
} else {
|
||||||
|
lost++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Nothing pinged since the start: show the newest saved step.
|
||||||
|
b := ps.Latency[len(ps.Latency)-1]
|
||||||
|
v.At = time.Unix(b.T, 0).Add(latencyStep)
|
||||||
|
if v.At.After(now) {
|
||||||
|
v.At = now
|
||||||
|
}
|
||||||
|
rtts, sent, lost = b.RTTs, b.Sent, b.Lost
|
||||||
|
if len(rtts) == 0 && b.Sent > b.Lost {
|
||||||
|
rtts = []float64{b.Min, b.Med, b.Max}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(rtts) > 0 {
|
||||||
|
var med float64
|
||||||
|
v.Min, med, v.Max = spread(rtts)
|
||||||
|
v.MS = &med
|
||||||
|
}
|
||||||
|
if sent > 0 {
|
||||||
|
v.Loss = lost * 100 / sent
|
||||||
|
}
|
||||||
|
for _, b := range s.latencySeries(ps, now, 12) {
|
||||||
|
if b.Sent > b.Lost {
|
||||||
|
m := b.Med
|
||||||
|
v.Spark = append(v.Spark, &m)
|
||||||
|
} else {
|
||||||
|
v.Spark = append(v.Spark, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// latencySeries returns n steps ending with the current one; steps without
|
||||||
|
// pings have Sent 0. s.mu must be held.
|
||||||
|
func (s *Stats) latencySeries(ps *peerStats, now time.Time, n int) []latBucket {
|
||||||
|
last := now.Truncate(latencyStep)
|
||||||
|
out := make([]latBucket, n)
|
||||||
|
idx := map[int64]int{}
|
||||||
|
for i := range out {
|
||||||
|
t := last.Add(-time.Duration(n-1-i) * latencyStep).Unix()
|
||||||
|
out[i].T = t
|
||||||
|
idx[t] = i
|
||||||
|
}
|
||||||
|
for _, b := range ps.Latency {
|
||||||
|
if i, ok := idx[b.T]; ok {
|
||||||
|
out[i] = b.summary()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// LatencyHistory returns the latency of the last 24 hours, one point per step.
|
||||||
|
func (s *Stats) LatencyHistory(id string) []latBucket {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
ps := s.data.Peers[id]
|
||||||
|
if ps == nil {
|
||||||
|
ps = &peerStats{}
|
||||||
|
}
|
||||||
|
return s.latencySeries(ps, time.Now(), int(latencyKeep/latencyStep))
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneLatency drops history older than latencyKeep. s.mu must be held.
|
||||||
|
func pruneLatency(ps *peerStats, now time.Time) bool {
|
||||||
|
cut := now.Add(-latencyKeep).Truncate(latencyStep).Unix()
|
||||||
|
i := 0
|
||||||
|
for i < len(ps.Latency) && ps.Latency[i].T < cut {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ps.Latency = append([]latBucket(nil), ps.Latency[i:]...)
|
||||||
|
return true
|
||||||
|
}
|
||||||
@@ -208,9 +208,10 @@ func run(configPath string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(3)
|
wg.Add(4)
|
||||||
go func() { defer wg.Done(); recon.Run(stop) }()
|
go func() { defer wg.Done(); recon.Run(stop) }()
|
||||||
go func() { defer wg.Done(); stats.Run(stop) }()
|
go func() { defer wg.Done(); stats.Run(stop) }()
|
||||||
|
go func() { defer wg.Done(); stats.RunPings(stop) }()
|
||||||
go func() { defer wg.Done(); geo.Run(stop) }()
|
go func() { defer wg.Done(); geo.Run(stop) }()
|
||||||
go func() {
|
go func() {
|
||||||
t := time.NewTicker(10 * time.Minute)
|
t := time.NewTicker(10 * time.Minute)
|
||||||
|
|||||||
@@ -162,6 +162,13 @@ func (k *fakeKernel) Checks(*Config) []Check { return []Check{{"fak
|
|||||||
func (k *fakeKernel) Uplink(*Config, bool) string { return "eth0" }
|
func (k *fakeKernel) Uplink(*Config, bool) string { return "eth0" }
|
||||||
func (k *fakeKernel) Down(*Config) error { return nil }
|
func (k *fakeKernel) Down(*Config) error { return nil }
|
||||||
func (k *fakeKernel) Close() error { return nil }
|
func (k *fakeKernel) Close() error { return nil }
|
||||||
|
func (k *fakeKernel) Ping(dsts []netip.Addr, _ time.Duration) (map[netip.Addr]time.Duration, error) {
|
||||||
|
out := map[netip.Addr]time.Duration{}
|
||||||
|
for _, d := range dsts {
|
||||||
|
out[d] = 20 * time.Millisecond
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func TestStatsDeltas(t *testing.T) {
|
func TestStatsDeltas(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
@@ -681,3 +688,53 @@ func TestInstallQuestions(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLatency(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
store, err := openStore(filepath.Join(dir, "config.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key, _ := newPrivateKey()
|
||||||
|
pub := key.PublicKey().String()
|
||||||
|
ip := serverIPv4(netip.MustParsePrefix(store.Get().Server.IPv4)).Next()
|
||||||
|
if err := store.Update(func(c *Config) error {
|
||||||
|
c.Peers = append(c.Peers, Peer{ID: "p1", Name: "phone", IPv4: ip.String(), PublicKey: pub, Enabled: true, LatencyCheck: latencyActive})
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.Update(func(c *Config) error { c.Peers[0].LatencyCheck = "sometimes"; return nil }); err == nil {
|
||||||
|
t.Fatal("invalid latency check accepted")
|
||||||
|
}
|
||||||
|
k := &fakeKernel{}
|
||||||
|
st, _ := openStats(filepath.Join(dir, "stats.json"), store, k)
|
||||||
|
now := time.Now()
|
||||||
|
k.samples = []PeerSample{{PublicKey: pub, RxBytes: 100, LastHandshake: now}}
|
||||||
|
st.sample()
|
||||||
|
k.samples[0].RxBytes += 200 // below activeRxBytes: idle, e.g. keepalives
|
||||||
|
st.sample()
|
||||||
|
if got := st.pingTargets(store.Get(), now); len(got) != 0 {
|
||||||
|
t.Fatalf("idle peer pinged: %v", got)
|
||||||
|
}
|
||||||
|
k.samples[0].RxBytes += 50_000
|
||||||
|
st.sample()
|
||||||
|
if got := st.pingTargets(store.Get(), time.Now()); got[ip] != "p1" {
|
||||||
|
t.Fatalf("active peer not pinged: %v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
st.mu.Lock()
|
||||||
|
for _, ms := range []int{30, 10, 20} {
|
||||||
|
st.record("p1", now, time.Duration(ms)*time.Millisecond, true)
|
||||||
|
}
|
||||||
|
st.record("p1", now, 0, false)
|
||||||
|
st.mu.Unlock()
|
||||||
|
l := st.Summary("p1").Latency
|
||||||
|
if l == nil || l.MS == nil || *l.MS != 20 || l.Min != 10 || l.Max != 30 || l.Loss != 25 {
|
||||||
|
t.Fatalf("latency %+v", l)
|
||||||
|
}
|
||||||
|
h := st.LatencyHistory("p1")
|
||||||
|
if last := h[len(h)-1]; last.Sent != 4 || last.Lost != 1 || last.Med != 20 || last.RTTs != nil {
|
||||||
|
t.Fatalf("history %+v", last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math/rand/v2"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/icmp"
|
||||||
|
"golang.org/x/net/ipv4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// listenICMP opens an unprivileged ICMP socket, which the kernel allows for
|
||||||
|
// the groups in net.ipv4.ping_group_range (systemd opens it to all groups).
|
||||||
|
// Running as root, a raw socket works too.
|
||||||
|
func listenICMP() (c *icmp.PacketConn, raw bool, err error) {
|
||||||
|
if c, err = icmp.ListenPacket("udp4", "0.0.0.0"); err == nil {
|
||||||
|
return c, false, nil
|
||||||
|
}
|
||||||
|
if c, err2 := icmp.ListenPacket("ip4:icmp", "0.0.0.0"); err2 == nil {
|
||||||
|
return c, true, nil
|
||||||
|
}
|
||||||
|
return nil, false, errors.New("cannot open an ICMP socket (" + err.Error() + "): add the service's group to the sysctl net.ipv4.ping_group_range")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *linuxKernel) Ping(dsts []netip.Addr, timeout time.Duration) (map[netip.Addr]time.Duration, error) {
|
||||||
|
conn, raw, err := listenICMP()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
// On an unprivileged socket the kernel sets the ID and delivers only the
|
||||||
|
// socket's own replies; on a raw socket the ID tells them apart.
|
||||||
|
id := rand.IntN(0xffff) + 1
|
||||||
|
bySeq := map[int]netip.Addr{}
|
||||||
|
sent := map[netip.Addr]time.Time{}
|
||||||
|
for i, d := range dsts {
|
||||||
|
seq := i + 1
|
||||||
|
b, err := (&icmp.Message{Type: ipv4.ICMPTypeEcho, Body: &icmp.Echo{ID: id, Seq: seq, Data: []byte(appName)}}).Marshal(nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var to net.Addr = &net.UDPAddr{IP: d.AsSlice()}
|
||||||
|
if raw {
|
||||||
|
to = &net.IPAddr{IP: d.AsSlice()}
|
||||||
|
}
|
||||||
|
sent[d] = time.Now()
|
||||||
|
if _, err := conn.WriteTo(b, to); err != nil {
|
||||||
|
delete(sent, d) // e.g. the peer has no endpoint: counts as lost
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bySeq[seq] = d
|
||||||
|
}
|
||||||
|
out := map[netip.Addr]time.Duration{}
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(timeout))
|
||||||
|
buf := make([]byte, 1500)
|
||||||
|
for len(out) < len(sent) {
|
||||||
|
n, from, err := conn.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
break // deadline reached
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
m, err := icmp.ParseMessage(1, buf[:n])
|
||||||
|
if err != nil || m.Type != ipv4.ICMPTypeEchoReply {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e, ok := m.Body.(*icmp.Echo)
|
||||||
|
if !ok || (raw && e.ID != id) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var src netip.Addr
|
||||||
|
switch a := from.(type) {
|
||||||
|
case *net.UDPAddr:
|
||||||
|
src, _ = netip.AddrFromSlice(a.IP)
|
||||||
|
case *net.IPAddr:
|
||||||
|
src, _ = netip.AddrFromSlice(a.IP)
|
||||||
|
}
|
||||||
|
src = src.Unmap()
|
||||||
|
if d, ok := bySeq[e.Seq]; !ok || d != src {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, dup := out[src]; !dup {
|
||||||
|
out[src] = now.Sub(sent[src])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ type peerStats struct {
|
|||||||
Hourly []bucket `json:"hourly"`
|
Hourly []bucket `json:"hourly"`
|
||||||
Daily []bucket `json:"daily"`
|
Daily []bucket `json:"daily"`
|
||||||
Sessions []connSession `json:"sessions,omitempty"`
|
Sessions []connSession `json:"sessions,omitempty"`
|
||||||
|
Latency []latBucket `json:"latency,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// session is one stretch of a peer being online from one address. When the
|
// session is one stretch of a peer being online from one address. When the
|
||||||
@@ -114,10 +115,13 @@ type Stats struct {
|
|||||||
store *Store
|
store *Store
|
||||||
kernel Kernel
|
kernel Kernel
|
||||||
geo *Geo // nil: no country and network lookups
|
geo *Geo // nil: no country and network lookups
|
||||||
|
|
||||||
|
live map[string]*latLive // by peer ID
|
||||||
|
pingErr error // of the last latency check
|
||||||
}
|
}
|
||||||
|
|
||||||
func openStats(path string, store *Store, k Kernel) (*Stats, error) {
|
func openStats(path string, store *Store, k Kernel) (*Stats, error) {
|
||||||
s := &Stats{path: path, store: store, kernel: k, data: statsFile{Version: 1, Peers: map[string]*peerStats{}}}
|
s := &Stats{path: path, store: store, kernel: k, live: map[string]*latLive{}, data: statsFile{Version: 1, Peers: map[string]*peerStats{}}}
|
||||||
b, err := os.ReadFile(path)
|
b, err := os.ReadFile(path)
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return s, nil
|
return s, nil
|
||||||
@@ -182,7 +186,7 @@ func (s *Stats) prune(c StatsConfig, now time.Time) {
|
|||||||
if i > 0 {
|
if i > 0 {
|
||||||
ps.Sessions = append([]connSession(nil), ps.Sessions[i:]...)
|
ps.Sessions = append([]connSession(nil), ps.Sessions[i:]...)
|
||||||
}
|
}
|
||||||
if len(ps.Hourly) != h || len(ps.Daily) != dl || len(ps.Sessions) != sl {
|
if pruneLatency(ps, now) || len(ps.Hourly) != h || len(ps.Daily) != dl || len(ps.Sessions) != sl {
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,6 +227,9 @@ func (s *Stats) sample() {
|
|||||||
dRx, dTx = smp.RxBytes, smp.TxBytes
|
dRx, dTx = smp.RxBytes, smp.TxBytes
|
||||||
}
|
}
|
||||||
ps.LastRx, ps.LastTx = smp.RxBytes, smp.TxBytes
|
ps.LastRx, ps.LastTx = smp.RxBytes, smp.TxBytes
|
||||||
|
if dRx >= activeRxBytes {
|
||||||
|
s.liveFor(id).lastActive = now
|
||||||
|
}
|
||||||
if dRx > 0 || dTx > 0 {
|
if dRx > 0 || dTx > 0 {
|
||||||
ps.TotalRx += dRx
|
ps.TotalRx += dRx
|
||||||
ps.TotalTx += dTx
|
ps.TotalTx += dTx
|
||||||
@@ -241,6 +248,7 @@ func (s *Stats) sample() {
|
|||||||
for id, ps := range s.data.Peers {
|
for id, ps := range s.data.Peers {
|
||||||
if !exists[id] {
|
if !exists[id] {
|
||||||
delete(s.data.Peers, id)
|
delete(s.data.Peers, id)
|
||||||
|
delete(s.live, id)
|
||||||
s.dirty = true
|
s.dirty = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -359,6 +367,7 @@ type PeerSummary struct {
|
|||||||
DownTotal int64 `json:"downTotal"`
|
DownTotal int64 `json:"downTotal"`
|
||||||
UpTotal int64 `json:"upTotal"`
|
UpTotal int64 `json:"upTotal"`
|
||||||
Location *GeoInfo `json:"location"` // of the current or last endpoint
|
Location *GeoInfo `json:"location"` // of the current or last endpoint
|
||||||
|
Latency *LatencyView `json:"latency"` // null = never pinged
|
||||||
}
|
}
|
||||||
|
|
||||||
func sumPoints(pts []Point) (down, up int64) {
|
func sumPoints(pts []Point) (down, up int64) {
|
||||||
@@ -386,6 +395,7 @@ func (s *Stats) Summary(id string) PeerSummary {
|
|||||||
if cur := ps.openSession(); cur != nil && cur.Geo != nil && hostOf(cur.Endpoint) == hostOf(ps.Endpoint) {
|
if cur := ps.openSession(); cur != nil && cur.Geo != nil && hostOf(cur.Endpoint) == hostOf(ps.Endpoint) {
|
||||||
out.Location = cur.Geo
|
out.Location = cur.Geo
|
||||||
}
|
}
|
||||||
|
out.Latency = s.latencyView(id, ps, time.Now())
|
||||||
}
|
}
|
||||||
if out.Location == nil && out.Endpoint != "" {
|
if out.Location == nil && out.Endpoint != "" {
|
||||||
out.Location = s.geo.Lookup(out.Endpoint)
|
out.Location = s.geo.Lookup(out.Endpoint)
|
||||||
|
|||||||
Reference in New Issue
Block a user