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:
Daniel Redetzke
2026-10-05 11:59:27 +03:00
parent 6662adf0b8
commit ae95781427
9 changed files with 587 additions and 17 deletions
+13 -1
View File
@@ -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.
+20
View File
@@ -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 {
+25 -1
View File
@@ -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 */
@@ -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: 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; }
.ev:last-child { border-bottom: 0; }
+142 -6
View File
@@ -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)),
@@ -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();
+22 -7
View File
@@ -27,15 +27,24 @@ type Config struct {
APITokens []APIToken `json:"apiTokens"`
// Admin is the single account of config version 1; applyDefaults moves
// it into Users.
Admin *Admin `json:"admin,omitempty"`
Server Server `json:"server"`
Peers []Peer `json:"peers"`
Log LogConfig `json:"log"`
Stats StatsConfig `json:"stats"`
Decoy DecoyConfig `json:"decoy"`
SignIn SignInConfig `json:"signin"`
Admin *Admin `json:"admin,omitempty"`
Server Server `json:"server"`
Peers []Peer `json:"peers"`
Log LogConfig `json:"log"`
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 == "" {
+3 -2
View File
@@ -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()
+1
View File
@@ -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)
+250
View File
@@ -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
View File
@@ -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")
}
}