Add a Check for updates button under Settings; keep the self edge when settings are saved
CI / test (push) Successful in 4m48s

Saving settings replaced the whole Settings value, which would have dropped SelfEdgeID and made the
next start create a second self edge with a new token.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-26 16:02:27 -06:00
1 parent da3d7201a1
commit abf352e6c9
10 files changed
+135 -1

No files matched your search

+2
View File
@@ -78,6 +78,8 @@ func main() {
SelfDomain: os.Getenv("HAMMERHEAD_SELF_DOMAIN"),
SelfTarget: localAddr(*listen),
SelfTunnel: os.Getenv("HAMMERHEAD_SELF_TUNNEL"),
Version: version,
AutoUpdate: *autoUpdate,
})
if err != nil {
log.Fatal(err)
+1 -1
View File
@@ -5,7 +5,7 @@ go 1.27.3
require (
git.veillax.com/Veillax/bamboo v0.1.3
git.veillax.com/Veillax/blacktip v0.1.4
git.veillax.com/Veillax/reef v0.1.3
git.veillax.com/Veillax/reef v0.1.4
github.com/emersion/go-msgauth v0.7.0
golang.org/x/crypto v0.58.0
modernc.org/sqlite v1.61.0
+2
View File
@@ -10,6 +10,8 @@ git.veillax.com/Veillax/reef v0.1.0 h1:O0OhEKFx2CsvACJ20sfzJNjGsngomtfDdKdxu4ROp
git.veillax.com/Veillax/reef v0.1.0/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
git.veillax.com/Veillax/reef v0.1.3 h1:RxSq2xwaup1+MieAkhlvOTZnYwPf19thFHEAe/7VxsI=
git.veillax.com/Veillax/reef v0.1.3/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
git.veillax.com/Veillax/reef v0.1.4 h1:T2+B8P1GAitEyTYRkwMT3UHbG9mN5zXb/inE2muXdQ4=
git.veillax.com/Veillax/reef v0.1.4/go.mod h1:ZBUd/BARO5kkpUNTP81TTY1aQXLHFR6bKDhCxckjHso=
github.com/Microsoft/go-winio v0.6.4 h1:kJF2pamtYCWXf0xAT8sAkwKC0pMl2HYBrRPHAqyUc+g=
github.com/Microsoft/go-winio v0.6.4/go.mod h1:1xl5JXG4ze/m74GH6Dc4Mwxfc3VL5siRVQjIAlHH07U=
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
+23
View File
@@ -96,3 +96,26 @@ func TestNoSelfEdgeUnlessAsked(t *testing.T) {
t.Fatal("token file written without HAMMERHEAD_SELF_DOMAIN")
}
}
func TestSavingSettingsKeepsTheSelfEdge(t *testing.T) {
dir := t.TempDir()
opts := Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MasterKey: key, SelfDomain: "hh.example.com", SelfTarget: "127.0.0.1:8080"}
s, err := New(dir, opts)
if err != nil {
t.Fatal(err)
}
token := tokenFile(t, dir)
do := apiClient(t, s)
if code, b := do("PUT", "/api/admin/settings", map[string]any{"dashboardUrl": "https://hh.example.com", "acmeEmail": "me@example.com"}); code != 200 {
t.Fatalf("settings: %d %s", code, b)
}
s.Close()
s2, err := New(dir, opts)
if err != nil {
t.Fatal(err)
}
defer s2.Close()
if len(s2.st.Edges) != 1 || tokenFile(t, dir) != token {
t.Fatalf("saving settings lost the self edge: %d edges", len(s2.st.Edges))
}
}
+1
View File
@@ -97,6 +97,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/admin/settings", s.withServerAdmin(s.getSettings))
mux.HandleFunc("PUT /api/admin/settings", s.withServerAdmin(s.putSettings))
mux.HandleFunc("POST /api/admin/settings/test-mail", s.withServerAdmin(s.testMail))
mux.HandleFunc("GET /api/admin/update", s.withServerAdmin(s.checkUpdate))
mux.HandleFunc("GET /api/admin/audit", s.withServerAdmin(func(w http.ResponseWriter, r *http.Request, p *principal) {
writeJSON(w, 200, s.logs.queryAudit("", 300, 0))
}))
+1
View File
@@ -41,6 +41,7 @@ func (s *Server) putSettings(w http.ResponseWriter, r *http.Request, p *principa
if s.opts.DashboardURL != "" {
in.DashboardURL = s.st.Settings.DashboardURL // pinned by flag/env
}
in.SelfEdgeID = s.st.Settings.SelfEdgeID // not editable here; losing it would make the next start create a second self edge
s.st.Settings = in
s.bump() // edges receive the dashboard URL and ACME settings
s.mu.Unlock()
+8
View File
@@ -180,6 +180,14 @@ type Options struct {
SelfDomain string
SelfTarget string
SelfTunnel string
// Version is the running release (e.g. v0.1.5), shown under Settings with a check for updates.
Version string
// AutoUpdate says whether this process updates itself (HAMMERHEAD_AUTO_UPDATE), for the same screen.
AutoUpdate bool
// UpdateServer and UpdateRepo say where releases are published; the defaults are this project's own.
UpdateServer string
UpdateRepo string
}
type Server struct {
+31
View File
@@ -0,0 +1,31 @@
package hammerhead
import (
"context"
"net/http"
"time"
"git.veillax.com/Veillax/reef/selfupdate"
)
// checkUpdate asks the release server whether a newer Hammerhead exists. It only looks: updating is
// left to auto-update or to the person running the server.
func (s *Server) checkUpdate(w http.ResponseWriter, r *http.Request, p *principal) {
o := s.opts
cfg := selfupdate.Config{Server: o.UpdateServer, Repo: o.UpdateRepo, App: "hammerhead", Current: o.Version}
if cfg.Server == "" {
cfg.Server = "https://git.veillax.com"
}
if cfg.Repo == "" {
cfg.Repo = "Veillax/hammerhead"
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
out := map[string]any{"current": o.Version, "autoUpdate": o.AutoUpdate, "checked": time.Now()}
latest, newer, err := selfupdate.Check(ctx, cfg)
out["latest"], out["newer"] = latest, newer
if err != nil {
out["error"] = err.Error()
}
writeJSON(w, 200, out)
}
+54
View File
@@ -0,0 +1,54 @@
package hammerhead
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
)
func TestCheckForUpdates(t *testing.T) {
gitea := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/repos/Veillax/hammerhead/releases/latest" {
http.NotFound(w, r)
return
}
fmt.Fprint(w, `{"tag_name":"v0.3.0","prerelease":false,"assets":[]}`)
}))
defer gitea.Close()
for _, c := range []struct {
version string
newer bool
}{{"v0.2.0", true}, {"v0.3.0", false}, {"dev", false}} {
s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MasterKey: key,
Version: c.version, UpdateServer: gitea.URL, UpdateRepo: "Veillax/hammerhead", AutoUpdate: true})
if err != nil {
t.Fatal(err)
}
do := apiClient(t, s)
code, b := do("GET", "/api/admin/update", nil)
var out struct {
Current, Latest, Error string
Newer, AutoUpdate bool
}
json.Unmarshal(b, &out)
if code != 200 || out.Current != c.version || out.Latest != "v0.3.0" || out.Newer != c.newer || !out.AutoUpdate || out.Error != "" {
t.Errorf("%s: %d %s", c.version, code, b)
}
s.Close()
}
// an unreachable release server is reported, not fatal
s, err := New(t.TempDir(), Options{AdminEmail: "admin@test.local", AdminPassword: "password123", MasterKey: key, Version: "v0.1.0", UpdateServer: "http://127.0.0.1:1"})
if err != nil {
t.Fatal(err)
}
defer s.Close()
code, b := apiClient(t, s)("GET", "/api/admin/update", nil)
var out struct{ Error string }
json.Unmarshal(b, &out)
if code != 200 || out.Error == "" {
t.Errorf("unreachable server: %d %s", code, b)
}
}
+12
View File
@@ -925,12 +925,24 @@ serverViews.settings = async () => {
const staging = chk('Use the Let\'s Encrypt staging server (untrusted test certificates)', s.acmeStaging);
const save = act(async () => { await api('PUT', '/api/admin/settings', {dashboardUrl:f.url.value, acmeEmail:f.acme.value, acmeStaging:staging.box.checked,
smtp:{host:f.host.value, port:+f.port.value, user:f.user.value, pass:f.pass.value, from:f.from.value, mode:f.mode.value}}); S.me = await api('GET', '/api/me'); toast('Saved'); });
const updOut = h('div', {class:'dim'}, 'Not checked yet.');
const checkUpdate = act(async () => {
updOut.textContent = 'Checking…';
const u = await api('GET', '/api/admin/update');
const dev = !/^v\d+\.\d+\.\d+$/.test(u.current || '');
if (u.error) updOut.textContent = 'Running ' + (u.current || 'an unknown version') + '. Could not check for updates: ' + u.error;
else if (dev) updOut.textContent = 'This is a development build (' + (u.current || 'unversioned') + '). The newest release is ' + u.latest + '.';
else if (u.newer) updOut.textContent = 'Running ' + u.current + '. ' + u.latest + ' is available. ' + (u.autoUpdate ? 'Auto-update is on, so it installs by itself within about six hours.' : 'Auto-update is off: install it yourself, or set HAMMERHEAD_AUTO_UPDATE=1 and restart.');
else updOut.textContent = 'Running ' + u.current + ', which is the newest release.';
});
return h('div', {},
card('General', h('div', {class:'stack'}, field('Dashboard URL', f.url, 'The address people use to reach Hammerhead. Used for login redirects and SSO. Include https:// if you serve it over TLS.'),
field('Let\'s Encrypt contact email', f.acme, 'Used for certificate expiry notices'), staging)),
card('Email', h('p', {class:'dim'}, 'Needed for invitation emails and one-time sign-in codes.'),
h('div', {class:'grid'}, field('SMTP host', f.host), field('Port', f.port), field('Security', f.mode), field('Username', f.user), field('Password', f.pass), field('From', f.from)),
h('div', {class:'actions', style:'margin-top:10px'}, h('button', {class:'ghost', onclick: act(async () => { await save(); await api('POST', '/api/admin/settings/test-mail'); toast('Test email sent to ' + S.me.email); })}, 'Save and send a test email'))),
card('Updates', h('p', {class:'dim'}, 'Hammerhead, Blacktip and Bamboo can update themselves from new releases when auto-update is on (HAMMERHEAD_AUTO_UPDATE=1). This checks the newest release for Hammerhead; it installs nothing.'),
updOut, h('div', {class:'actions', style:'margin-top:10px'}, h('button', {class:'ghost', onclick:checkUpdate}, 'Check for updates'))),
h('button', {class:'btn', onclick:save}, 'Save settings'));
};