Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0861047952 | |||
| ac572e165a | |||
| dbeaa645c0 |
@@ -37,6 +37,11 @@ dependencies on the server: the binary installs, updates and removes itself.
|
||||
retention.
|
||||
- **Logs:** written to `GHOSTWIRE.jsonl`, rotated at 10 MB with 5 old files
|
||||
kept by default. Changes are marked as audit entries.
|
||||
- **Update notice:** once a day the server asks Gitea or GitHub (your choice
|
||||
under Settings → Updates) for the latest release. A newer one shows in the
|
||||
sidebar, on the Dashboard and in Settings, with its release notes and the
|
||||
commands to update this server. Nothing about the server is sent; the check
|
||||
can be switched off.
|
||||
- **HTTPS built in:** Let's Encrypt, a self-signed certificate, your own
|
||||
certificate files, or plain HTTP behind a reverse proxy.
|
||||
|
||||
@@ -264,7 +269,7 @@ POST /peers/{id}/enable | /disable | /issue-config
|
||||
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 /settings PATCH /settings POST /restart
|
||||
GET /settings PATCH /settings POST /restart POST /updates/check
|
||||
GET /logs?level=&limit=&audit=1 GET /logs/download
|
||||
signed in: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore
|
||||
public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link opens)
|
||||
@@ -275,6 +280,13 @@ public: GET /setup/{token} · POST /setup/{token} {"pin"} (what a setup link o
|
||||
setup link (`setup.url`, `setup.pin`, `setup.qr`) instead of a config. With a
|
||||
link, the peer's current keys keep working until the link is opened.
|
||||
|
||||
`GET /settings` includes `updates`: the running and latest version,
|
||||
`available`, the release notes and the download links for this server's
|
||||
platform. `PATCH /settings` `{"updates": {"source": "gitea"|"github",
|
||||
"check": false}}` picks the source or switches the daily check off;
|
||||
`POST /updates/check` checks now. `GET /auth/me` has `updateAvailable` with
|
||||
the newer version while there is one.
|
||||
|
||||
Traffic is reported from the peer's point of view: `down` is what the peer
|
||||
downloaded, `up` is what it uploaded.
|
||||
|
||||
@@ -303,6 +315,9 @@ Settings → Pair iOS app: scan the QR code, or tap "Copy pairing code" and past
|
||||
it into the app's "Enter manually". Self-signed certificates are pinned during
|
||||
pairing.
|
||||
|
||||
The iOS app is currently in beta testing. For an invite, email
|
||||
[engineroom@redetzke.aero](mailto:engineroom@redetzke.aero).
|
||||
|
||||
## Development
|
||||
|
||||
On macOS (or any non-Linux system), `make dev` starts the app on
|
||||
|
||||
@@ -26,6 +26,7 @@ type App struct {
|
||||
logPath string
|
||||
logw *rotatingWriter // nil in tests
|
||||
geo *Geo // nil in tests
|
||||
updates *Updater // nil in tests
|
||||
started time.Time
|
||||
shutdown func() // graceful stop; systemd restarts the service
|
||||
}
|
||||
@@ -173,6 +174,7 @@ func (a *App) routes() http.Handler {
|
||||
// need a signed-in user.
|
||||
g("GET /api/v1/settings", a.getSettings)
|
||||
g("PATCH /api/v1/settings", a.patchSettings)
|
||||
g("POST /api/v1/updates/check", a.checkUpdates)
|
||||
g("POST /api/v1/restart", a.restart)
|
||||
adm("GET /api/v1/tokens", a.listTokens)
|
||||
adm("POST /api/v1/tokens", a.createToken)
|
||||
@@ -258,6 +260,9 @@ func (a *App) me(w http.ResponseWriter, r *http.Request) {
|
||||
"id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope,
|
||||
"mustChangePassword": p.MustChangePassword, "mfaSetupRequired": p.MFASetupRequired, "version": version, "session": p.Session,
|
||||
}
|
||||
if v := a.updates.Available(); v != "" {
|
||||
out["updateAvailable"] = v
|
||||
}
|
||||
if _, u := a.store.Get().userByID(p.UserID); u != nil {
|
||||
out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created
|
||||
}
|
||||
@@ -999,6 +1004,7 @@ func (a *App) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||
"decoy": cfg.Decoy,
|
||||
"signin": cfg.SignIn,
|
||||
"geo": a.geoStatus(),
|
||||
"updates": a.updates.Status(),
|
||||
"fingerprint": a.tls.Fingerprint(),
|
||||
"logPath": a.logPath,
|
||||
})
|
||||
@@ -1031,6 +1037,9 @@ func (a *App) patchSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if err := field(m, "signin", &c.SignIn); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := field(m, "updates", &c.Updates); err != nil {
|
||||
return err
|
||||
}
|
||||
return field(m, "log", &c.Log)
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1176,6 +1185,17 @@ func (a *App) applyRuntime(c *Config) {
|
||||
a.logw.SetLimits(c.Log.MaxSizeMB, c.Log.MaxFiles)
|
||||
}
|
||||
a.geo.SetEnabled(c.Stats.geoEnabled())
|
||||
a.updates.Set(c.Updates)
|
||||
}
|
||||
|
||||
// checkUpdates asks the release source now and returns what it found.
|
||||
func (a *App) checkUpdates(w http.ResponseWriter, r *http.Request) {
|
||||
if a.updates == nil || !a.updates.Status().Enabled {
|
||||
writeErr(w, badRequest("the update check is switched off"))
|
||||
return
|
||||
}
|
||||
a.updates.Check(r.Context())
|
||||
writeJSON(w, http.StatusOK, a.updates.Status())
|
||||
}
|
||||
|
||||
func (a *App) geoStatus() GeoStatus {
|
||||
|
||||
@@ -69,7 +69,10 @@ a.brand, a.brand:hover { color: #fff; text-decoration: none; }
|
||||
.side .acct strong { font-size: 14px; font-weight: 500; color: #fff; overflow: hidden; text-overflow: ellipsis; }
|
||||
.side .acct span span { color: #a9aaa5; }
|
||||
.side .avatar { width: 28px; height: 28px; border-radius: 50%; background: #3a3b41; display: grid; place-items: center; flex: none; font-size: 13px; font-weight: 600; color: #fff; }
|
||||
.side .footrow { display: flex; justify-content: space-between; padding: 10px 12px 0; }
|
||||
.side .footrow { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 12px 0; }
|
||||
.side .footrow .upd { font-size: 11.5px; font-weight: 500; color: #cfe2f8; background: #1f3550; border: 1px solid #2d4a6e; padding: 2px 8px; border-radius: 999px; text-decoration: none; white-space: nowrap; }
|
||||
.side .footrow .upd:hover { color: #fff; }
|
||||
.side .nav .pip { margin-left: auto; width: 7px; height: 7px; border-radius: 50%; background: #6aa6ea; }
|
||||
.main { flex: 999 1 560px; min-width: 0; padding: 32px 32px 56px; }
|
||||
/* Beside the page (not stacked above it on a phone), the sidebar stays in
|
||||
place while the page scrolls, so the account link is always visible. */
|
||||
@@ -129,6 +132,9 @@ h1 { margin: 0; font-size: 26px; font-weight: 600; letter-spacing: -0.01em; over
|
||||
.tag { display: inline-block; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; background: var(--warn-bg); color: var(--warn-ink); margin-left: 6px; vertical-align: 1px; }
|
||||
.notice { display: flex; gap: 10px; align-items: flex-start; padding: 12px 14px; border-radius: 10px; background: var(--warn-bg); color: var(--warn-ink); font-size: 13px; }
|
||||
.notice.err { background: #fbefee; color: var(--bad-ink); }
|
||||
.notice.new { background: #e8f0fa; color: #174d8f; flex-wrap: wrap; align-items: center; }
|
||||
.notice.new .actions { margin-left: auto; }
|
||||
.btn.ghost { background: transparent; border-color: transparent; }
|
||||
.notice .btn { margin-left: auto; }
|
||||
|
||||
/* tables */
|
||||
@@ -173,28 +179,50 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-d
|
||||
.kv dt { color: var(--ink-2); }
|
||||
.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
|
||||
|
||||
/* health: public addresses, then one tile per check */
|
||||
/* health: public addresses on the left, one row per check on the right */
|
||||
.hcbody { display: grid; grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); gap: 12px; margin-top: 16px; }
|
||||
.hchead { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
|
||||
.hchead > span { font-size: 13px; color: var(--ink-2); }
|
||||
.hchead > span.bad { color: var(--bad-ink); font-weight: 500; }
|
||||
.hcaddrs { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; margin-top: 16px; }
|
||||
.hcaddr { background: var(--ground); border-radius: 10px; padding: 14px 16px; min-width: 0; }
|
||||
.hcaddrs { display: flex; flex-direction: column; gap: 12px; }
|
||||
.hcaddr { flex: 1; display: flex; flex-direction: column; justify-content: center; background: var(--ground); border-radius: 10px; padding: 14px 18px; min-width: 0; }
|
||||
.hcaddr .l { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ink-2); }
|
||||
.hcaddr .v { margin-top: 4px; font-size: 15px; font-weight: 500; overflow-wrap: anywhere; }
|
||||
.hcaddr .v.mono { font-size: 20px; }
|
||||
.hcaddr .v.mono { font-size: 22px; }
|
||||
.hcaddr .n { font-family: var(--sans); font-size: 12px; font-weight: 400; color: var(--ink-2); }
|
||||
.hcaddr.bad { background: #fdf6f5; box-shadow: inset 0 0 0 1px #e6b3b0; }
|
||||
.hcaddr.bad .v { color: var(--bad-ink); }
|
||||
.hctiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px; margin-top: 12px; }
|
||||
.hctile { border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.hctile .l { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--ink-2); }
|
||||
.hctile .l > span:first-child { flex: 1; min-width: 0; }
|
||||
.hctile .s { font-size: 15px; font-weight: 500; }
|
||||
.hctile .r { font-size: 11.5px; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||
.hctile .p { font-size: 12.5px; color: var(--bad-ink); overflow-wrap: anywhere; }
|
||||
.hctile.bad { border-color: #e6b3b0; background: #fdf6f5; }
|
||||
.hctile.bad .s { color: var(--bad-ink); }
|
||||
@media (max-width: 640px) { .hctiles { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
|
||||
.hclist { border: 1px solid var(--line); border-radius: 10px; min-width: 0; }
|
||||
.hcrow { display: grid; grid-template-columns: 8px minmax(0, 190px) minmax(0, 1fr); gap: 2px 14px; align-items: baseline; padding: 11px 16px; border-top: 1px solid var(--line-2); }
|
||||
.hcrow:first-child { border-top: 0; }
|
||||
.hcrow > .dot { align-self: center; }
|
||||
.hcrow .l { font-size: 13px; color: var(--ink-2); }
|
||||
.hcrow .v { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 10px; min-width: 0; }
|
||||
.hcrow .s { font-weight: 500; }
|
||||
.hcrow .r { font-size: 12px; color: var(--ink-3); overflow-wrap: anywhere; }
|
||||
.hcrow .p { grid-column: 2 / -1; font-size: 12.5px; color: var(--bad-ink); overflow-wrap: anywhere; }
|
||||
.hcrow.bad { background: #fdf6f5; }
|
||||
.hcrow.bad .s { color: var(--bad-ink); }
|
||||
@media (max-width: 1000px) { .hcbody { grid-template-columns: minmax(0, 1fr); } }
|
||||
@media (max-width: 640px) { .hcrow { grid-template-columns: 8px minmax(0, 1fr); } .hcrow .v { grid-column: 2; } }
|
||||
|
||||
/* updates */
|
||||
.upvers { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-top: 14px; }
|
||||
.upbox { background: var(--ground); border-radius: 10px; padding: 12px 14px; display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.upbox > span { font-size: 12px; color: var(--ink-2); }
|
||||
.upbox strong { font-size: 15px; font-weight: 500; }
|
||||
.upbox strong.mono { font-size: 16px; }
|
||||
.upbox.new { background: #e8f0fa; box-shadow: inset 0 0 0 1px #bcd2ee; }
|
||||
.upbox.new strong { color: #174d8f; }
|
||||
.uptodate { display: flex; align-items: center; gap: 10px; margin: 14px 0 0; font-weight: 500; }
|
||||
.upnotes { border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; margin-top: 14px; display: flex; flex-direction: column; gap: 10px; font-size: 13px; }
|
||||
.upnotes p { margin: 0; max-width: 80ch; }
|
||||
.upnotes ul { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; max-width: 80ch; }
|
||||
.upnotes .hd, .upcmd .hd { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
||||
.upnotes .hd a { margin-left: auto; }
|
||||
.upcmd { display: flex; flex-direction: column; gap: 8px; margin-top: 14px; }
|
||||
.uprow { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--line-2); }
|
||||
#updates > .notice { margin-top: 14px; }
|
||||
|
||||
/* activity */
|
||||
.ev { display: flex; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
|
||||
|
||||
@@ -530,7 +530,7 @@
|
||||
|
||||
const NAV = [['#/', 'dashboard', 'Dashboard'], ['#/peers', 'peers', 'Peers'], ['#/server', 'server', 'Server'], ['#/settings', 'settings', 'Settings']];
|
||||
let navLinks = {};
|
||||
let srvBox, peerCount;
|
||||
let srvBox, peerCount, verRow;
|
||||
|
||||
function buildShell() {
|
||||
srvBox = h('div', { class: 'srv' }, h('span', { class: 'dot' }), h('span', null, 'Loading…'));
|
||||
@@ -546,13 +546,25 @@
|
||||
h('span', { class: 'avatar', 'aria-hidden': 'true' }, me.name.slice(0, 1).toUpperCase()),
|
||||
h('span', null, h('strong', null, me.name), h('span', null, 'My account')))),
|
||||
h('button', { type: 'button', class: 'signout', 'aria-label': 'Sign out', onClick: logout }, icon('logout'), h('span', { class: 'tip', 'aria-hidden': 'true' }, 'Sign out'))),
|
||||
h('div', { class: 'footrow' },
|
||||
h('span', null, 'v' + me.version.replace(/^v/, '')))));
|
||||
(verRow = h('div', { class: 'footrow' }))));
|
||||
main = h('main', { class: 'main', id: 'main' });
|
||||
app.replaceChildren(h('div', { class: 'shell' }, nav, main));
|
||||
drawUpdateHint();
|
||||
refreshSide();
|
||||
}
|
||||
|
||||
// drawUpdateHint shows a newer release next to the version in the sidebar
|
||||
// and as a dot on Settings.
|
||||
function drawUpdateHint() {
|
||||
if (!verRow) return;
|
||||
const v = me.updateAvailable;
|
||||
fill(verRow, h('span', null, 'v' + me.version.replace(/^v/, '')),
|
||||
v ? h('a', { class: 'upd', href: '#/settings#updates' }, v + ' available') : null);
|
||||
const set = navLinks['#/settings'];
|
||||
set.querySelectorAll('.pip, .sr').forEach((e) => e.remove());
|
||||
if (v) set.append(h('span', { class: 'pip', title: 'Update available' }), h('span', { class: 'sr' }, ', update available'));
|
||||
}
|
||||
|
||||
async function refreshSide() {
|
||||
try {
|
||||
const s = await api('GET', '/status');
|
||||
@@ -582,7 +594,7 @@
|
||||
[/^#\/peers\/new$/, '#/peers', viewPeerNew],
|
||||
[/^#\/peers\/([\w-]+)$/, '#/peers', viewPeer],
|
||||
[/^#\/server$/, '#/server', viewServer],
|
||||
[/^#\/settings$/, '#/settings', viewSettings],
|
||||
[/^#\/settings(#updates)?$/, '#/settings', viewSettings],
|
||||
[/^#\/account$/, '#/account', viewAccount],
|
||||
];
|
||||
|
||||
@@ -1005,6 +1017,8 @@
|
||||
h('div', null, h('strong', null, 'Needs attention: '), failing.map((c) => c.name + ' (' + c.detail + ')').join(' · ')),
|
||||
h('a', { class: 'btn small', href: '#/server' }, 'Health')) : null,
|
||||
|
||||
updateBanner(),
|
||||
|
||||
h('div', { class: 'tiles' },
|
||||
h('div', { class: 'card tile' }, h('div', { class: 'k' }, 'Peers online'),
|
||||
h('div', { class: 'v' }, String(st.peers.online), h('small', null, '/ ' + st.peers.total)),
|
||||
@@ -1511,9 +1525,9 @@
|
||||
const DNS_PRESETS = [['Quad9', '9.9.9.9, 149.112.112.112']];
|
||||
|
||||
// healthParts turns the server's checks into the Health card: the public
|
||||
// address per IP family (from its uplink and public address checks), then
|
||||
// one tile per other check with a plain-word status, the raw setting and,
|
||||
// when it fails, what is wrong.
|
||||
// address per IP family (from its uplink and public address checks) and,
|
||||
// beside them, one row per other check with a plain-word status, the raw
|
||||
// setting and, when it fails, what is wrong.
|
||||
function healthParts(checks) {
|
||||
const by = Object.fromEntries(checks.map((c) => [c.name, c]));
|
||||
const addrs = [];
|
||||
@@ -1563,14 +1577,14 @@
|
||||
return h('section', { class: 'card', 'aria-labelledby': 'hc' },
|
||||
h('div', { class: 'hchead' }, h('h2', { id: 'hc' }, 'Health'),
|
||||
h('span', { class: hp.failing ? 'bad' : null }, hp.failing ? hp.failing + ' of ' + hp.total + ' checks failing' : 'All ' + hp.total + ' checks pass')),
|
||||
h('div', { class: 'hcbody' },
|
||||
hp.addrs.length ? h('div', { class: 'hcaddrs' }, hp.addrs.map((a) => h('div', { class: a.ok ? 'hcaddr' : 'hcaddr bad' },
|
||||
h('div', { class: 'l' }, dot(a.ok), a.label),
|
||||
h('div', { class: a.mono ? 'v mono' : 'v' }, a.value, a.note ? h('span', { class: 'n' }, ' ' + a.note) : null)))) : null,
|
||||
h('div', { class: 'hctiles' }, hp.tiles.map((t) => h('div', { class: t.ok ? 'hctile' : 'hctile bad', title: t.title || null },
|
||||
h('div', { class: 'l' }, h('span', null, t.label), dot(t.ok)),
|
||||
h('div', { class: 's' }, t.status),
|
||||
t.raw ? h('div', { class: 'r mono' }, t.raw) : null,
|
||||
t.problem ? h('div', { class: 'p' }, t.problem) : null))));
|
||||
h('div', { class: 'hclist' }, hp.tiles.map((t) => h('div', { class: t.ok ? 'hcrow' : 'hcrow bad', title: t.title || null },
|
||||
dot(t.ok), h('span', { class: 'l' }, t.label),
|
||||
h('span', { class: 'v' }, h('span', { class: 's' }, t.status), t.raw ? h('span', { class: 'r mono' }, t.raw) : null),
|
||||
t.problem ? h('div', { class: 'p' }, t.problem) : null)))));
|
||||
}
|
||||
|
||||
async function viewServer(wrap) {
|
||||
@@ -1693,6 +1707,125 @@
|
||||
bar);
|
||||
}
|
||||
|
||||
// ---------- updates ----------
|
||||
|
||||
const HIDE_UPDATE = 'GHOSTWIRE.hideUpdate';
|
||||
|
||||
// updateBanner tells the Dashboard about a newer release until it is
|
||||
// hidden for that version.
|
||||
function updateBanner() {
|
||||
const v = me.updateAvailable;
|
||||
let hidden = null;
|
||||
try { hidden = localStorage.getItem(HIDE_UPDATE); } catch { /* storage blocked */ }
|
||||
if (!v || hidden === v) return null;
|
||||
const box = h('div', { class: 'notice new' },
|
||||
h('div', null, h('strong', null, 'GHOSTWIRE ' + v + ' is available. '), 'You\'re on v' + me.version.replace(/^v/, '') + '.'),
|
||||
h('div', { class: 'actions' },
|
||||
h('a', { class: 'btn small', href: '#/settings#updates' }, 'How to update'),
|
||||
h('button', { type: 'button', class: 'btn small ghost', onClick: () => {
|
||||
try { localStorage.setItem(HIDE_UPDATE, v); } catch { /* storage blocked */ }
|
||||
box.remove();
|
||||
} }, 'Hide until the next version')));
|
||||
return box;
|
||||
}
|
||||
|
||||
// mdInline turns **bold** and `code` into elements; everything else stays
|
||||
// text.
|
||||
const mdInline = (text) => text.split(/(\*\*[^*]+\*\*|`[^`]+`)/).filter(Boolean).map((t) =>
|
||||
t.startsWith('**') ? h('strong', null, t.slice(2, -2)) : t.startsWith('`') ? h('code', null, t.slice(1, -1)) : t);
|
||||
|
||||
// releaseSummary picks the opening paragraph and the first bullet list out
|
||||
// of the release notes; the full notes are a link away.
|
||||
function releaseSummary(md) {
|
||||
const lines = md.replace(/\r/g, '').split('\n');
|
||||
const para = [];
|
||||
for (const l of lines) {
|
||||
if (!l.trim()) { if (para.length) break; continue; }
|
||||
if (/^(#|- |\* |```|\|)/.test(l)) break;
|
||||
para.push(l.trim());
|
||||
}
|
||||
const items = [];
|
||||
let started = false;
|
||||
for (const l of lines) {
|
||||
const m = /^[-*] (.+)$/.exec(l);
|
||||
if (m) { started = true; items.push(m[1]); } else if (started && l.trim()) break;
|
||||
}
|
||||
return { summary: para.join(' '), items };
|
||||
}
|
||||
|
||||
function updatesCard(initial) {
|
||||
let st = initial;
|
||||
const card = h('section', { class: 'card', id: 'updates', 'aria-labelledby': 'upd' });
|
||||
const setStatus = (next) => {
|
||||
st = next;
|
||||
me.updateAvailable = st.enabled && st.available ? st.latest.version : undefined;
|
||||
drawUpdateHint();
|
||||
draw();
|
||||
};
|
||||
const checkNow = async (btn) => {
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Checking…'; }
|
||||
try { setStatus(await api('POST', '/updates/check')); } catch (x) { toast(x.message, true); draw(); }
|
||||
};
|
||||
const save = async (updates) => {
|
||||
try {
|
||||
await api('PATCH', '/settings', { updates });
|
||||
const s = await api('GET', '/settings');
|
||||
if (s.updates.enabled) await checkNow(); else setStatus(s.updates);
|
||||
} catch (x) { toast(x.message, true); draw(); }
|
||||
};
|
||||
const SOURCES = [['gitea', 'Gitea', 'git.redetzke.aero/Redetzke/GHOSTWIRE'], ['github', 'GitHub', 'github.com/danielredetzke/GHOSTWIRE']];
|
||||
const srcName = () => SOURCES.find(([k]) => k === st.source)[1];
|
||||
|
||||
function draw() {
|
||||
const cur = 'v' + st.current.replace(/^v/, '');
|
||||
const rel = st.latest;
|
||||
const notes = rel && st.available ? releaseSummary(rel.notes || '') : null;
|
||||
const cmds = st.available && st.file ? [
|
||||
'curl -fLO ' + st.fileUrl,
|
||||
'curl -fLO ' + st.sumsUrl,
|
||||
'sha256sum -c --ignore-missing SHA256SUMS',
|
||||
'chmod +x ' + st.file,
|
||||
'sudo ./' + st.file + ' update',
|
||||
].join('\n') : null;
|
||||
fill(card,
|
||||
h('div', { class: 'cardhead' },
|
||||
h('h2', { id: 'upd' }, 'Updates'),
|
||||
st.enabled ? h('span', { class: 'muted' }, st.checked ? 'Last checked ' + ago(st.checked) : 'Not checked yet') : null),
|
||||
h('div', { class: 'upvers' },
|
||||
h('div', { class: 'upbox' }, h('span', null, 'Running'), h('strong', { class: 'mono' }, cur)),
|
||||
rel ? h('div', { class: st.available ? 'upbox new' : 'upbox' }, h('span', null, 'Latest release'), h('strong', { class: 'mono' }, rel.version)) : null,
|
||||
h('div', { class: 'upbox' }, h('span', null, 'This server'), h('strong', null, st.arch ? 'Linux · ' + st.arch : 'No release file for this platform'))),
|
||||
st.enabled && st.error ? h('div', { class: 'notice err', role: 'alert' },
|
||||
h('div', null, 'The last check failed: ' + st.error + '. ' + (st.lastOk ? 'Last worked ' + ago(st.lastOk) + '. ' : '') + 'Try the other source.')) : null,
|
||||
rel && !st.available ? h('p', { class: 'uptodate' }, h('span', { class: 'dot ok' }), 'GHOSTWIRE is up to date.') : null,
|
||||
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())),
|
||||
/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,
|
||||
cmds ? h('div', { class: 'upcmd' },
|
||||
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,
|
||||
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 }) }),
|
||||
h('span', null, h('strong', null, name), h('br'), h('span', { class: 'hint mono' }, where))))),
|
||||
h('span', { class: 'hint' }, 'Both carry the same releases and files. The check, the release notes and the download links use the source you pick.')),
|
||||
h('div', { class: 'uprow' },
|
||||
h('label', { class: 'check' },
|
||||
h('input', { type: 'checkbox', checked: st.enabled, onChange: (e) => save({ check: e.target.checked }) }),
|
||||
h('span', null, 'Check for updates once a day', h('br'),
|
||||
h('span', { class: 'hint' }, 'Asks ' + new URL(st.sourceUrl).host + ' for the latest release. Nothing about this server is sent.'))),
|
||||
st.enabled ? h('button', { type: 'button', class: 'btn small', onClick: (e) => checkNow(e.currentTarget) }, 'Check now') : null));
|
||||
}
|
||||
draw();
|
||||
return card;
|
||||
}
|
||||
|
||||
// ---------- settings ----------
|
||||
|
||||
// ---------- my account ----------
|
||||
@@ -2071,7 +2204,7 @@
|
||||
} });
|
||||
|
||||
fill(wrap,
|
||||
h('div', null, h('h1', null, 'Settings'), h('p', { class: 'sub' }, 'Users, web interface, API access for the iOS app, logs, data retention and backups')),
|
||||
h('div', null, h('h1', null, 'Settings'), h('p', { class: 'sub' }, 'Users, web interface, API access for the iOS app, logs, data retention, backups and updates')),
|
||||
restartBox,
|
||||
|
||||
h('section', { class: 'card flush', 'aria-labelledby': 'usr' },
|
||||
@@ -2146,8 +2279,11 @@
|
||||
h('div', { class: 'actions' },
|
||||
h('a', { class: 'btn', href: '/api/v1/backup' }, 'Download backup'),
|
||||
h('button', { type: 'button', class: 'btn', onClick: () => restoreInput.click() }, 'Restore from file…'),
|
||||
restoreInput)));
|
||||
restoreInput)),
|
||||
|
||||
updatesCard(s.updates));
|
||||
await drawLogs();
|
||||
if (location.hash.endsWith('#updates')) document.getElementById('updates').scrollIntoView();
|
||||
}
|
||||
|
||||
render();
|
||||
|
||||
@@ -34,8 +34,17 @@ type Config struct {
|
||||
Stats StatsConfig `json:"stats"`
|
||||
Decoy DecoyConfig `json:"decoy"`
|
||||
SignIn SignInConfig `json:"signin"`
|
||||
Updates UpdatesConfig `json:"updates"`
|
||||
}
|
||||
|
||||
// UpdatesConfig sets the daily check for a newer release.
|
||||
type UpdatesConfig struct {
|
||||
Check *bool `json:"check,omitempty"` // default on
|
||||
Source string `json:"source"` // gitea | github, see updateSources
|
||||
}
|
||||
|
||||
func (c UpdatesConfig) checkEnabled() bool { return c.Check == nil || *c.Check }
|
||||
|
||||
// SignInConfig holds the rules for signing in to the web interface.
|
||||
type SignInConfig struct {
|
||||
// RequireMFA sends users without two-step sign-in to set it up before
|
||||
@@ -246,6 +255,9 @@ func (c *Config) applyDefaults() {
|
||||
if c.Decoy.Page == "" {
|
||||
c.Decoy.Page = "nginx"
|
||||
}
|
||||
if c.Updates.Source == "" {
|
||||
c.Updates.Source = "gitea"
|
||||
}
|
||||
if c.APITokens == nil {
|
||||
c.APITokens = []APIToken{}
|
||||
}
|
||||
@@ -382,6 +394,9 @@ func (c *Config) validate() error {
|
||||
if _, ok := decoyPages[c.Decoy.Page]; !ok {
|
||||
return fmt.Errorf("unknown decoy page %q", c.Decoy.Page)
|
||||
}
|
||||
if _, ok := updateSources[c.Updates.Source]; !ok {
|
||||
return fmt.Errorf("update source must be gitea or github")
|
||||
}
|
||||
switch c.Web.TLS.Mode {
|
||||
case "acme":
|
||||
if c.Web.TLS.Domain == "" {
|
||||
|
||||
@@ -222,15 +222,16 @@ func run(configPath string) error {
|
||||
auth := newAuth(store)
|
||||
app := &App{
|
||||
store: store, kernel: kernel, recon: recon, stats: stats, auth: auth, tls: webTLS,
|
||||
logPath: logPath, logw: logw, geo: geo, started: time.Now(), shutdown: shutdown,
|
||||
logPath: logPath, logw: logw, geo: geo, updates: newUpdater(cfg.Updates), started: time.Now(), shutdown: shutdown,
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(4)
|
||||
wg.Add(5)
|
||||
go func() { defer wg.Done(); recon.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(); app.updates.Run(stop) }()
|
||||
go func() {
|
||||
t := time.NewTicker(10 * time.Minute)
|
||||
defer t.Stop()
|
||||
|
||||
@@ -78,6 +78,7 @@ func TestValidate(t *testing.T) {
|
||||
"bad dns": func(c *Config) { c.Peers[0].DNS = []string{"dns.example"} },
|
||||
"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" },
|
||||
} {
|
||||
cc := c.clone()
|
||||
mutate(cc)
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The update check asks one of the two places releases are published for
|
||||
// the latest one. Both carry the same tags and files.
|
||||
var updateSources = map[string]struct {
|
||||
Name string // shown in the web interface
|
||||
API string // latest release, as JSON
|
||||
Repo string // web page of the repository; downloads are under it
|
||||
}{
|
||||
"gitea": {"Gitea", "https://git.redetzke.aero/api/v1/repos/Redetzke/GHOSTWIRE/releases/latest", "https://git.redetzke.aero/Redetzke/GHOSTWIRE"},
|
||||
"github": {"GitHub", "https://api.github.com/repos/danielredetzke/GHOSTWIRE/releases/latest", "https://github.com/danielredetzke/GHOSTWIRE"},
|
||||
}
|
||||
|
||||
const updateCheckFreq = 24 * time.Hour
|
||||
|
||||
// Release is the latest published release as the source reports it.
|
||||
type Release struct {
|
||||
Version string `json:"version"` // tag, e.g. "v0.4.0"
|
||||
Published time.Time `json:"published"`
|
||||
Notes string `json:"notes"` // Markdown
|
||||
URL string `json:"url"` // release page
|
||||
}
|
||||
|
||||
// UpdateStatus is shown in the settings; Available also reaches the sidebar
|
||||
// and the Dashboard through /auth/me.
|
||||
type UpdateStatus struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Source string `json:"source"`
|
||||
Current string `json:"current"`
|
||||
Latest *Release `json:"latest"`
|
||||
Available bool `json:"available"` // Latest is newer than Current
|
||||
Checked *time.Time `json:"checked"` // last attempt
|
||||
Error string `json:"error,omitempty"`
|
||||
LastOK *time.Time `json:"lastOk"` // last attempt that worked
|
||||
// Download links for this server's platform; empty when no release
|
||||
// file is built for it.
|
||||
Arch string `json:"arch"`
|
||||
File string `json:"file,omitempty"`
|
||||
FileURL string `json:"fileUrl,omitempty"`
|
||||
SumsURL string `json:"sumsUrl,omitempty"`
|
||||
SourceURL string `json:"sourceUrl"` // repository page of the source
|
||||
}
|
||||
|
||||
type Updater struct {
|
||||
enabled atomic.Bool
|
||||
kick chan struct{}
|
||||
fetch func(ctx context.Context, url string) (*Release, error) // replaced in tests
|
||||
|
||||
mu sync.Mutex
|
||||
source string
|
||||
latest *Release
|
||||
checked *time.Time
|
||||
lastOK *time.Time
|
||||
err string
|
||||
}
|
||||
|
||||
func newUpdater(c UpdatesConfig) *Updater {
|
||||
u := &Updater{kick: make(chan struct{}, 1), fetch: fetchRelease, source: c.Source}
|
||||
u.enabled.Store(c.checkEnabled())
|
||||
return u
|
||||
}
|
||||
|
||||
// Set applies the settings. A new source or switching the check on checks
|
||||
// at once; switching it off forgets what the last check found.
|
||||
func (u *Updater) Set(c UpdatesConfig) {
|
||||
if u == nil {
|
||||
return
|
||||
}
|
||||
on := c.checkEnabled()
|
||||
u.mu.Lock()
|
||||
changed := u.source != c.Source || u.enabled.Load() != on
|
||||
if u.source != c.Source || !on {
|
||||
u.latest, u.checked, u.lastOK, u.err = nil, nil, nil, ""
|
||||
}
|
||||
u.source = c.Source
|
||||
u.enabled.Store(on)
|
||||
u.mu.Unlock()
|
||||
if changed && on {
|
||||
select {
|
||||
case u.kick <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run checks once a day while the check is on.
|
||||
func (u *Updater) Run(stop <-chan struct{}) {
|
||||
t := time.NewTicker(updateCheckFreq)
|
||||
defer t.Stop()
|
||||
for {
|
||||
if u.enabled.Load() {
|
||||
u.Check(context.Background())
|
||||
}
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
case <-u.kick:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check asks the source for the latest release now.
|
||||
func (u *Updater) Check(ctx context.Context) {
|
||||
u.mu.Lock()
|
||||
source := u.source
|
||||
u.mu.Unlock()
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
rel, err := u.fetch(ctx, updateSources[source].API)
|
||||
now := time.Now()
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
if u.source != source { // the source changed meanwhile; that check counts
|
||||
return
|
||||
}
|
||||
u.checked = &now
|
||||
if err != nil {
|
||||
u.err = err.Error()
|
||||
slog.Warn("update check failed", "source", source, "err", err)
|
||||
return
|
||||
}
|
||||
u.latest, u.lastOK, u.err = rel, &now, ""
|
||||
if newerVersion(rel.Version, version) {
|
||||
slog.Info("update available", "version", rel.Version, "running", version)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *Updater) Status() UpdateStatus {
|
||||
if u == nil {
|
||||
return UpdateStatus{Current: version}
|
||||
}
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
src := updateSources[u.source]
|
||||
st := UpdateStatus{
|
||||
Enabled: u.enabled.Load(), Source: u.source, Current: version, Latest: u.latest,
|
||||
Checked: u.checked, Error: u.err, LastOK: u.lastOK, Arch: releaseArch(), SourceURL: src.Repo,
|
||||
}
|
||||
if u.latest != nil {
|
||||
st.Available = newerVersion(u.latest.Version, version)
|
||||
if st.Arch != "" {
|
||||
st.File = fmt.Sprintf("%s-%s-linux-%s", appName, u.latest.Version, st.Arch)
|
||||
base := src.Repo + "/releases/download/" + u.latest.Version + "/"
|
||||
st.FileURL, st.SumsURL = base+st.File, base+"SHA256SUMS"
|
||||
}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
// Available returns the newer release's version, or "".
|
||||
func (u *Updater) Available() string {
|
||||
if st := u.Status(); st.Enabled && st.Available {
|
||||
return st.Latest.Version
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// releaseArch names this platform the way the release files do, or "" when
|
||||
// no file is built for it.
|
||||
func releaseArch() string {
|
||||
if runtime.GOOS != "linux" {
|
||||
return ""
|
||||
}
|
||||
switch runtime.GOARCH {
|
||||
case "amd64", "arm64":
|
||||
return runtime.GOARCH
|
||||
case "arm":
|
||||
return "armv7"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fetchRelease(ctx context.Context, url string) (*Release, error) {
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", appName+"/"+strings.TrimPrefix(version, "v"))
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("HTTP %d from %s", resp.StatusCode, req.URL.Host)
|
||||
}
|
||||
// GitHub and Gitea name these fields the same.
|
||||
var r struct {
|
||||
Tag string `json:"tag_name"`
|
||||
Body string `json:"body"`
|
||||
Published time.Time `json:"published_at"`
|
||||
URL string `json:"html_url"`
|
||||
Draft bool `json:"draft"`
|
||||
Prerelease bool `json:"prerelease"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&r); err != nil {
|
||||
return nil, fmt.Errorf("unreadable answer from %s: %w", req.URL.Host, err)
|
||||
}
|
||||
if r.Draft || r.Prerelease || parseVersion(r.Tag) == nil {
|
||||
return nil, errors.New("the latest release is not a published version")
|
||||
}
|
||||
return &Release{Version: r.Tag, Published: r.Published, Notes: r.Body, URL: r.URL}, nil
|
||||
}
|
||||
|
||||
var versionRe = regexp.MustCompile(`^v?(\d+)\.(\d+)\.(\d+)`)
|
||||
|
||||
// parseVersion reads "v0.4.0", "0.4.0" or "v0.4.0-3-gb18d16a" (a build
|
||||
// after v0.4.0) as major, minor and patch, or nil.
|
||||
func parseVersion(s string) []int {
|
||||
m := versionRe.FindStringSubmatch(s)
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]int, 3)
|
||||
for i := range out {
|
||||
out[i], _ = strconv.Atoi(m[i+1])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// newerVersion reports whether latest is a higher version than running.
|
||||
// A running version that is not a version number is never out of date.
|
||||
func newerVersion(latest, running string) bool {
|
||||
l, r := parseVersion(latest), parseVersion(running)
|
||||
if l == nil || r == nil {
|
||||
return false
|
||||
}
|
||||
for i := range l {
|
||||
if l[i] != r[i] {
|
||||
return l[i] > r[i]
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewerVersion(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
latest, running string
|
||||
want bool
|
||||
}{
|
||||
{"v0.4.0", "v0.3.2", true},
|
||||
{"v0.4.0", "0.3.2", true},
|
||||
{"v0.10.0", "v0.9.9", true},
|
||||
{"v1.0.0", "v0.99.0", true},
|
||||
{"v0.4.0", "v0.4.0", false},
|
||||
{"v0.4.0", "v0.4.0-3-gb18d16a", false}, // a build after the release
|
||||
{"v0.3.2", "v0.4.0", false},
|
||||
{"v0.4.0", "dev", false}, // not a version: never out of date
|
||||
{"latest", "v0.3.2", false},
|
||||
} {
|
||||
if got := newerVersion(tc.latest, tc.running); got != tc.want {
|
||||
t.Errorf("newerVersion(%q, %q) = %v, want %v", tc.latest, tc.running, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRelease(t *testing.T) {
|
||||
var body string
|
||||
var status int
|
||||
var ua string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ua = r.Header.Get("User-Agent")
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
status, body = 200, `{"tag_name":"v0.4.0","body":"Fixes.","published_at":"2026-10-05T06:15:28Z","html_url":"https://example.net/r/v0.4.0","draft":false,"prerelease":false}`
|
||||
r, err := fetchRelease(context.Background(), srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Version != "v0.4.0" || r.Notes != "Fixes." || r.URL != "https://example.net/r/v0.4.0" || r.Published.IsZero() {
|
||||
t.Fatalf("release = %+v", r)
|
||||
}
|
||||
if !strings.HasPrefix(ua, appName+"/") {
|
||||
t.Errorf("User-Agent = %q", ua)
|
||||
}
|
||||
|
||||
status, body = 200, `{"tag_name":"v0.5.0-rc1","prerelease":true}`
|
||||
if _, err := fetchRelease(context.Background(), srv.URL); err == nil {
|
||||
t.Error("a pre-release was accepted")
|
||||
}
|
||||
status, body = 404, `{}`
|
||||
if _, err := fetchRelease(context.Background(), srv.URL); err == nil || !strings.Contains(err.Error(), "404") {
|
||||
t.Errorf("HTTP 404: err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdater(t *testing.T) {
|
||||
old := version
|
||||
version = "v0.3.2"
|
||||
defer func() { version = old }()
|
||||
|
||||
u := newUpdater(UpdatesConfig{Source: "gitea"})
|
||||
var asked string
|
||||
u.fetch = func(_ context.Context, url string) (*Release, error) {
|
||||
asked = url
|
||||
return &Release{Version: "v0.4.0"}, nil
|
||||
}
|
||||
u.Check(context.Background())
|
||||
if asked != updateSources["gitea"].API {
|
||||
t.Errorf("asked %q", asked)
|
||||
}
|
||||
st := u.Status()
|
||||
if !st.Available || u.Available() != "v0.4.0" || st.Checked == nil || st.LastOK == nil {
|
||||
t.Fatalf("status = %+v", st)
|
||||
}
|
||||
if st.Arch != "" {
|
||||
want := "https://git.redetzke.aero/Redetzke/GHOSTWIRE/releases/download/v0.4.0/GHOSTWIRE-v0.4.0-linux-" + st.Arch
|
||||
if st.FileURL != want || !strings.HasSuffix(st.SumsURL, "/v0.4.0/SHA256SUMS") {
|
||||
t.Errorf("downloads = %q, %q", st.FileURL, st.SumsURL)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed check keeps the last good answer and reports the error.
|
||||
u.fetch = func(context.Context, string) (*Release, error) { return nil, errors.New("no route to host") }
|
||||
u.Check(context.Background())
|
||||
if st := u.Status(); st.Error != "no route to host" || st.Latest == nil {
|
||||
t.Errorf("after a failed check: %+v", st)
|
||||
}
|
||||
|
||||
// Another source forgets what the old one said; switching off hides it.
|
||||
u.Set(UpdatesConfig{Source: "github"})
|
||||
if st := u.Status(); st.Latest != nil || st.Error != "" || st.SourceURL != updateSources["github"].Repo {
|
||||
t.Errorf("after changing the source: %+v", st)
|
||||
}
|
||||
off := false
|
||||
u.fetch = func(context.Context, string) (*Release, error) { return &Release{Version: "v0.4.0"}, nil }
|
||||
u.Check(context.Background())
|
||||
u.Set(UpdatesConfig{Source: "github", Check: &off})
|
||||
if u.Available() != "" || u.Status().Enabled {
|
||||
t.Error("still reports an update with the check off")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user