Update notice: a newer release shows in the web interface
Once a day the server asks Gitea or GitHub, as picked under Settings -> Updates, for the latest release. A newer one shows as a pill in the sidebar, a banner on the Dashboard and in the Updates card with its release notes and the commands to update this server. Drafts and pre-releases are ignored, nothing about the server is sent, and the check can be switched off. POST /updates/check checks now.
This commit is contained in:
@@ -37,6 +37,11 @@ dependencies on the server: the binary installs, updates and removes itself.
|
|||||||
retention.
|
retention.
|
||||||
- **Logs:** written to `GHOSTWIRE.jsonl`, rotated at 10 MB with 5 old files
|
- **Logs:** written to `GHOSTWIRE.jsonl`, rotated at 10 MB with 5 old files
|
||||||
kept by default. Changes are marked as audit entries.
|
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
|
- **HTTPS built in:** Let's Encrypt, a self-signed certificate, your own
|
||||||
certificate files, or plain HTTP behind a reverse proxy.
|
certificate files, or plain HTTP behind a reverse proxy.
|
||||||
|
|
||||||
@@ -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}/stats?range=… GET /peers/{id}/sessions?limit=100
|
||||||
GET /peers/{id}/latency (24 h, one point per 5 minutes)
|
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 POST /updates/check
|
||||||
GET /logs?level=&limit=&audit=1 GET /logs/download
|
GET /logs?level=&limit=&audit=1 GET /logs/download
|
||||||
signed in: GET|POST /tokens · DELETE /tokens/{id} · GET /backup · POST /restore
|
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)
|
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
|
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.
|
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
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ type App struct {
|
|||||||
logPath string
|
logPath string
|
||||||
logw *rotatingWriter // nil in tests
|
logw *rotatingWriter // nil in tests
|
||||||
geo *Geo // nil in tests
|
geo *Geo // nil in tests
|
||||||
|
updates *Updater // nil in tests
|
||||||
started time.Time
|
started time.Time
|
||||||
shutdown func() // graceful stop; systemd restarts the service
|
shutdown func() // graceful stop; systemd restarts the service
|
||||||
}
|
}
|
||||||
@@ -173,6 +174,7 @@ func (a *App) routes() http.Handler {
|
|||||||
// need a signed-in user.
|
// need a signed-in user.
|
||||||
g("GET /api/v1/settings", a.getSettings)
|
g("GET /api/v1/settings", a.getSettings)
|
||||||
g("PATCH /api/v1/settings", a.patchSettings)
|
g("PATCH /api/v1/settings", a.patchSettings)
|
||||||
|
g("POST /api/v1/updates/check", a.checkUpdates)
|
||||||
g("POST /api/v1/restart", a.restart)
|
g("POST /api/v1/restart", a.restart)
|
||||||
adm("GET /api/v1/tokens", a.listTokens)
|
adm("GET /api/v1/tokens", a.listTokens)
|
||||||
adm("POST /api/v1/tokens", a.createToken)
|
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,
|
"id": p.UserID, "name": p.Name, "isAdmin": p.IsAdmin, "scope": p.Scope,
|
||||||
"mustChangePassword": p.MustChangePassword, "mfaSetupRequired": p.MFASetupRequired, "version": version, "session": p.Session,
|
"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 {
|
if _, u := a.store.Get().userByID(p.UserID); u != nil {
|
||||||
out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created
|
out["username"], out["note"], out["created"] = u.Username, u.Note, u.Created
|
||||||
}
|
}
|
||||||
@@ -999,6 +1004,7 @@ func (a *App) getSettings(w http.ResponseWriter, r *http.Request) {
|
|||||||
"decoy": cfg.Decoy,
|
"decoy": cfg.Decoy,
|
||||||
"signin": cfg.SignIn,
|
"signin": cfg.SignIn,
|
||||||
"geo": a.geoStatus(),
|
"geo": a.geoStatus(),
|
||||||
|
"updates": a.updates.Status(),
|
||||||
"fingerprint": a.tls.Fingerprint(),
|
"fingerprint": a.tls.Fingerprint(),
|
||||||
"logPath": a.logPath,
|
"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 {
|
if err := field(m, "signin", &c.SignIn); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := field(m, "updates", &c.Updates); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return field(m, "log", &c.Log)
|
return field(m, "log", &c.Log)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1176,6 +1185,17 @@ func (a *App) applyRuntime(c *Config) {
|
|||||||
a.logw.SetLimits(c.Log.MaxSizeMB, c.Log.MaxFiles)
|
a.logw.SetLimits(c.Log.MaxSizeMB, c.Log.MaxFiles)
|
||||||
}
|
}
|
||||||
a.geo.SetEnabled(c.Stats.geoEnabled())
|
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 {
|
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 strong { font-size: 14px; font-weight: 500; color: #fff; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.side .acct span span { color: #a9aaa5; }
|
.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 .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; }
|
.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
|
/* 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. */
|
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; }
|
.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 { 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.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; }
|
.notice .btn { margin-left: auto; }
|
||||||
|
|
||||||
/* tables */
|
/* tables */
|
||||||
@@ -200,6 +206,24 @@ fieldset { border: 0; margin: 0; padding: 0; min-width: 0; display: flex; flex-d
|
|||||||
@media (max-width: 1000px) { .hcbody { grid-template-columns: minmax(0, 1fr); } }
|
@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; } }
|
@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 */
|
/* activity */
|
||||||
.ev { display: flex; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
|
.ev { display: flex; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--line-2); font-size: 13px; }
|
||||||
.ev:last-child { border-bottom: 0; }
|
.ev:last-child { border-bottom: 0; }
|
||||||
|
|||||||
@@ -530,7 +530,7 @@
|
|||||||
|
|
||||||
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']];
|
||||||
let navLinks = {};
|
let navLinks = {};
|
||||||
let srvBox, peerCount;
|
let srvBox, peerCount, verRow;
|
||||||
|
|
||||||
function buildShell() {
|
function buildShell() {
|
||||||
srvBox = h('div', { class: 'srv' }, h('span', { class: 'dot' }), h('span', null, 'Loading…'));
|
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', { 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('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('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' },
|
(verRow = h('div', { class: 'footrow' }))));
|
||||||
h('span', null, 'v' + me.version.replace(/^v/, '')))));
|
|
||||||
main = h('main', { class: 'main', id: 'main' });
|
main = h('main', { class: 'main', id: 'main' });
|
||||||
app.replaceChildren(h('div', { class: 'shell' }, nav, main));
|
app.replaceChildren(h('div', { class: 'shell' }, nav, main));
|
||||||
|
drawUpdateHint();
|
||||||
refreshSide();
|
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() {
|
async function refreshSide() {
|
||||||
try {
|
try {
|
||||||
const s = await api('GET', '/status');
|
const s = await api('GET', '/status');
|
||||||
@@ -582,7 +594,7 @@
|
|||||||
[/^#\/peers\/new$/, '#/peers', viewPeerNew],
|
[/^#\/peers\/new$/, '#/peers', viewPeerNew],
|
||||||
[/^#\/peers\/([\w-]+)$/, '#/peers', viewPeer],
|
[/^#\/peers\/([\w-]+)$/, '#/peers', viewPeer],
|
||||||
[/^#\/server$/, '#/server', viewServer],
|
[/^#\/server$/, '#/server', viewServer],
|
||||||
[/^#\/settings$/, '#/settings', viewSettings],
|
[/^#\/settings(#updates)?$/, '#/settings', viewSettings],
|
||||||
[/^#\/account$/, '#/account', viewAccount],
|
[/^#\/account$/, '#/account', viewAccount],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1005,6 +1017,8 @@
|
|||||||
h('div', null, h('strong', null, 'Needs attention: '), failing.map((c) => c.name + ' (' + c.detail + ')').join(' · ')),
|
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,
|
h('a', { class: 'btn small', href: '#/server' }, 'Health')) : null,
|
||||||
|
|
||||||
|
updateBanner(),
|
||||||
|
|
||||||
h('div', { class: 'tiles' },
|
h('div', { class: 'tiles' },
|
||||||
h('div', { class: 'card tile' }, h('div', { class: 'k' }, 'Peers online'),
|
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)),
|
h('div', { class: 'v' }, String(st.peers.online), h('small', null, '/ ' + st.peers.total)),
|
||||||
@@ -1693,6 +1707,125 @@
|
|||||||
bar);
|
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 ----------
|
// ---------- settings ----------
|
||||||
|
|
||||||
// ---------- my account ----------
|
// ---------- my account ----------
|
||||||
@@ -2071,7 +2204,7 @@
|
|||||||
} });
|
} });
|
||||||
|
|
||||||
fill(wrap,
|
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,
|
restartBox,
|
||||||
|
|
||||||
h('section', { class: 'card flush', 'aria-labelledby': 'usr' },
|
h('section', { class: 'card flush', 'aria-labelledby': 'usr' },
|
||||||
@@ -2146,8 +2279,11 @@
|
|||||||
h('div', { class: 'actions' },
|
h('div', { class: 'actions' },
|
||||||
h('a', { class: 'btn', href: '/api/v1/backup' }, 'Download backup'),
|
h('a', { class: 'btn', href: '/api/v1/backup' }, 'Download backup'),
|
||||||
h('button', { type: 'button', class: 'btn', onClick: () => restoreInput.click() }, 'Restore from file…'),
|
h('button', { type: 'button', class: 'btn', onClick: () => restoreInput.click() }, 'Restore from file…'),
|
||||||
restoreInput)));
|
restoreInput)),
|
||||||
|
|
||||||
|
updatesCard(s.updates));
|
||||||
await drawLogs();
|
await drawLogs();
|
||||||
|
if (location.hash.endsWith('#updates')) document.getElementById('updates').scrollIntoView();
|
||||||
}
|
}
|
||||||
|
|
||||||
render();
|
render();
|
||||||
|
|||||||
@@ -34,8 +34,17 @@ type Config struct {
|
|||||||
Stats StatsConfig `json:"stats"`
|
Stats StatsConfig `json:"stats"`
|
||||||
Decoy DecoyConfig `json:"decoy"`
|
Decoy DecoyConfig `json:"decoy"`
|
||||||
SignIn SignInConfig `json:"signin"`
|
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.
|
// SignInConfig holds the rules for signing in to the web interface.
|
||||||
type SignInConfig struct {
|
type SignInConfig struct {
|
||||||
// RequireMFA sends users without two-step sign-in to set it up before
|
// 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 == "" {
|
if c.Decoy.Page == "" {
|
||||||
c.Decoy.Page = "nginx"
|
c.Decoy.Page = "nginx"
|
||||||
}
|
}
|
||||||
|
if c.Updates.Source == "" {
|
||||||
|
c.Updates.Source = "gitea"
|
||||||
|
}
|
||||||
if c.APITokens == nil {
|
if c.APITokens == nil {
|
||||||
c.APITokens = []APIToken{}
|
c.APITokens = []APIToken{}
|
||||||
}
|
}
|
||||||
@@ -382,6 +394,9 @@ func (c *Config) validate() error {
|
|||||||
if _, ok := decoyPages[c.Decoy.Page]; !ok {
|
if _, ok := decoyPages[c.Decoy.Page]; !ok {
|
||||||
return fmt.Errorf("unknown decoy page %q", c.Decoy.Page)
|
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 {
|
switch c.Web.TLS.Mode {
|
||||||
case "acme":
|
case "acme":
|
||||||
if c.Web.TLS.Domain == "" {
|
if c.Web.TLS.Domain == "" {
|
||||||
|
|||||||
@@ -222,15 +222,16 @@ func run(configPath string) error {
|
|||||||
auth := newAuth(store)
|
auth := newAuth(store)
|
||||||
app := &App{
|
app := &App{
|
||||||
store: store, kernel: kernel, recon: recon, stats: stats, auth: auth, tls: webTLS,
|
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
|
var wg sync.WaitGroup
|
||||||
wg.Add(4)
|
wg.Add(5)
|
||||||
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(); stats.RunPings(stop) }()
|
||||||
go func() { defer wg.Done(); geo.Run(stop) }()
|
go func() { defer wg.Done(); geo.Run(stop) }()
|
||||||
|
go func() { defer wg.Done(); app.updates.Run(stop) }()
|
||||||
go func() {
|
go func() {
|
||||||
t := time.NewTicker(10 * time.Minute)
|
t := time.NewTicker(10 * time.Minute)
|
||||||
defer t.Stop()
|
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 dns": func(c *Config) { c.Peers[0].DNS = []string{"dns.example"} },
|
||||||
"bad port": func(c *Config) { c.Server.ListenPort = 70000 },
|
"bad port": func(c *Config) { c.Server.ListenPort = 70000 },
|
||||||
"unmasked net": func(c *Config) { c.Server.IPv4 = "10.84.12.5/24" },
|
"unmasked net": func(c *Config) { c.Server.IPv4 = "10.84.12.5/24" },
|
||||||
|
"update source": func(c *Config) { c.Updates.Source = "sourceforge" },
|
||||||
} {
|
} {
|
||||||
cc := c.clone()
|
cc := c.clone()
|
||||||
mutate(cc)
|
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