diff --git a/cmd/hammerhead/main.go b/cmd/hammerhead/main.go index 5ac1ebb..d301c79 100644 --- a/cmd/hammerhead/main.go +++ b/cmd/hammerhead/main.go @@ -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) diff --git a/go.mod b/go.mod index c6f2052..5b2123f 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 35f7dc9..a166eb4 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/selfedge_test.go b/selfedge_test.go index d9a8ae9..db6bf7d 100644 --- a/selfedge_test.go +++ b/selfedge_test.go @@ -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)) + } +} diff --git a/server.go b/server.go index 56dd0d1..d936ad1 100644 --- a/server.go +++ b/server.go @@ -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)) })) diff --git a/settings.go b/settings.go index 2d7e9cf..32c0405 100644 --- a/settings.go +++ b/settings.go @@ -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() diff --git a/store.go b/store.go index ca70380..9dde7ff 100644 --- a/store.go +++ b/store.go @@ -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 { diff --git a/update.go b/update.go new file mode 100644 index 0000000..67a0404 --- /dev/null +++ b/update.go @@ -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) +} diff --git a/update_test.go b/update_test.go new file mode 100644 index 0000000..1057df9 --- /dev/null +++ b/update_test.go @@ -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) + } +} diff --git a/web/index.html b/web/index.html index 3ae4096..5956ad8 100644 --- a/web/index.html +++ b/web/index.html @@ -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')); };