From ae95781427c9ea5b30a7928a0f405468b487b80b Mon Sep 17 00:00:00 2001 From: Daniel Redetzke Date: Mon, 5 Oct 2026 11:59:27 +0300 Subject: [PATCH] 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. --- README.md | 14 ++- api.go | 20 ++++ app.css | 26 ++++- app.js | 148 +++++++++++++++++++++++++++-- config.go | 29 ++++-- main.go | 5 +- main_test.go | 1 + update.go | 250 +++++++++++++++++++++++++++++++++++++++++++++++++ update_test.go | 111 ++++++++++++++++++++++ 9 files changed, 587 insertions(+), 17 deletions(-) create mode 100644 update.go create mode 100644 update_test.go diff --git a/README.md b/README.md index ccac278..43f5173 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/api.go b/api.go index 9d0686f..2a0d077 100644 --- a/api.go +++ b/api.go @@ -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 { diff --git a/app.css b/app.css index 3976e1f..a0ceb07 100644 --- a/app.css +++ b/app.css @@ -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; } diff --git a/app.js b/app.js index f4994b4..0491787 100644 --- a/app.js +++ b/app.js @@ -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(); diff --git a/config.go b/config.go index 6993c8a..0e80f98 100644 --- a/config.go +++ b/config.go @@ -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 == "" { diff --git a/main.go b/main.go index 419dbce..f0384a7 100644 --- a/main.go +++ b/main.go @@ -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() diff --git a/main_test.go b/main_test.go index 94f653c..f5d90e7 100644 --- a/main_test.go +++ b/main_test.go @@ -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) diff --git a/update.go b/update.go new file mode 100644 index 0000000..5274e54 --- /dev/null +++ b/update.go @@ -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 +} diff --git a/update_test.go b/update_test.go new file mode 100644 index 0000000..294b00d --- /dev/null +++ b/update_test.go @@ -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") + } +}