Add a Check for updates button under Settings; keep the self edge when settings are saved
CI / test (push) Successful in 4m48s
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:
1 parent
da3d7201a1
commit
abf352e6c9
10 files changed
+135
-1
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user