Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/cli/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,6 @@ var UpdateCMD = &cli.Command{
Channel: c.String("channel"),
Force: c.Bool("force"),
Restart: !c.Bool("no-restart"),
}, constant.Version, constant.BuildTime, cliLogger, nil)
}, constant.Version, constant.GitRevision, cliLogger)
},
}
7 changes: 7 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ func (c *Config) NeedSyncFromServer() bool {
return strings.Contains(c.PATH, "http")
}

// LastLoadTime returns the wall-clock timestamp of the most recent
// successful (or attempted — see LoadConfig) reload. Zero before the
// first call.
func (c *Config) LastLoadTime() time.Time {
return c.lastLoadTime
}

func (c *Config) LoadConfig(force bool) error {
if c.ReloadInterval > 0 && time.Since(c.lastLoadTime).Seconds() < float64(c.ReloadInterval) && !force {
c.l.Warnf("Skip Load Config, last load time: %s", c.lastLoadTime)
Expand Down
105 changes: 50 additions & 55 deletions internal/updater/updater.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,18 +29,6 @@ const (
systemdServiceName = "ehco"
)

// State is the phase of an Apply run; consumed by the web UI.
type State string

const (
StateChecking State = "checking"
StateDownloading State = "downloading"
StateInstalling State = "installing"
StateRestarting State = "restarting"
StateDone State = "done"
StateFailed State = "failed"
)

// CheckResult describes a release relative to the running binary.
type CheckResult struct {
Channel string `json:"channel"`
Expand Down Expand Up @@ -80,8 +68,9 @@ type ghRelease struct {
}

// Check resolves channel against currentVersion and queries GitHub.
// currentBuildTime is the ldflag-injected constant.BuildTime; empty is fine.
func Check(ctx context.Context, channel, currentVersion, currentBuildTime string) (*CheckResult, error) {
// currentRevision is the ldflag-injected constant.GitRevision; empty is
// tolerated (we'll just trust version-string equality in that case).
func Check(ctx context.Context, channel, currentVersion, currentRevision string) (*CheckResult, error) {
resolved, rel, err := pickRelease(ctx, channel, currentVersion)
if err != nil {
return nil, err
Expand All @@ -98,7 +87,7 @@ func Check(ctx context.Context, channel, currentVersion, currentBuildTime string
PublishedAt: rel.PublishedAt,
}
if latest == currentVersion {
res.UpdateAvailable = nightlyRepublished(rel, currentBuildTime)
res.UpdateAvailable = nightlyRepublished(ctx, rel, currentRevision)
} else {
res.UpdateAvailable = compareVersions(latest, currentVersion) > 0
}
Expand All @@ -109,16 +98,12 @@ func Check(ctx context.Context, channel, currentVersion, currentBuildTime string
return res, nil
}

// Apply downloads + swaps + (optionally) restarts. Each phase is reported
// to onState so the dashboard can render progress; CLI passes nil.
func Apply(ctx context.Context, opts ApplyOptions, currentVersion, currentBuildTime string, log *zap.SugaredLogger, onState func(State)) error {
emit := func(s State) {
if onState != nil {
onState(s)
}
}

emit(StateChecking)
// Apply downloads + swaps + (optionally) restarts. Errors are returned
// for the caller to surface; phase tracking has been intentionally
// dropped — the systemd restart kills this process before any
// "restarting/done" state could be polled, making intermediate states
// unreliable. The dashboard polls /version to detect completion.
func Apply(ctx context.Context, opts ApplyOptions, currentVersion, currentRevision string, log *zap.SugaredLogger) error {
resolved, rel, err := pickRelease(ctx, opts.Channel, currentVersion)
if err != nil {
return err
Expand All @@ -128,12 +113,11 @@ func Apply(ctx context.Context, opts ApplyOptions, currentVersion, currentBuildT

if !opts.Force {
if latest == currentVersion {
if nightlyRepublished(rel, currentBuildTime) {
log.Infof("nightly tag %s republished after local build (%s); reinstalling",
rel.TagName, currentBuildTime)
if nightlyRepublished(ctx, rel, currentRevision) {
log.Infof("nightly tag %s now points at a different commit than local %s; reinstalling",
rel.TagName, currentRevision)
} else {
log.Info("already up to date")
emit(StateDone)
return nil
}
} else if compareVersions(latest, currentVersion) < 0 {
Expand All @@ -155,14 +139,12 @@ func Apply(ctx context.Context, opts ApplyOptions, currentVersion, currentBuildT
}
tmpPath := binPath + ".new"

emit(StateDownloading)
log.Infof("downloading %s -> %s", asset.BrowserDownloadURL, tmpPath)
if err := download(ctx, asset.BrowserDownloadURL, tmpPath); err != nil {
_ = os.Remove(tmpPath)
return fmt.Errorf("download: %w", err)
}

emit(StateInstalling)
// rename(2) over a running ELF on linux is safe: the kernel keeps the
// old inode alive for the running process while new invocations
// resolve to the new file.
Expand All @@ -178,15 +160,9 @@ func Apply(ctx context.Context, opts ApplyOptions, currentVersion, currentBuildT

if !opts.Restart {
log.Info("skipping restart; restart manually to pick up the new binary")
emit(StateDone)
return nil
}
emit(StateRestarting)
if err := restartSystemd(log); err != nil {
return err
}
emit(StateDone)
return nil
return restartSystemd(log)
}

func pickRelease(ctx context.Context, channel, currentVersion string) (string, *ghRelease, error) {
Expand Down Expand Up @@ -269,30 +245,49 @@ func getJSON(ctx context.Context, url string, out any) error {
}

// nightlyRepublished reports whether a release whose tag matches the
// running version is actually newer than the current binary. Nightly
// uses a rolling tag (v1.1.7-next), so version-string equality alone
// would mask republished builds. Only meaningful for prereleases; stable
// tags don't roll.
func nightlyRepublished(rel *ghRelease, currentBuildTime string) bool {
if !rel.Prerelease || currentBuildTime == "" {
// running version actually points at a different commit than the
// running binary. Nightly uses a rolling tag (v1.1.7-next), so
// version-string equality alone would mask republished builds.
//
// We can't time-compare published_at vs BuildTime: goreleaser publishes
// the release a few seconds after building the artifact, so the same
// artifact would always look "older" than its own release. SHA is the
// only reliable signal.
//
// Conservative on uncertainty: empty currentRevision (bare `go build`)
// or GitHub fetch failure -> false (no spurious update prompts; user
// can --force).
func nightlyRepublished(ctx context.Context, rel *ghRelease, currentRevision string) bool {
if !rel.Prerelease || currentRevision == "" {
return false
}
built, ok := parseBuildTime(currentBuildTime)
if !ok {
sha, err := fetchTagCommitSHA(ctx, rel.TagName)
if err != nil || sha == "" {
return false
}
return rel.PublishedAt.After(built)
return !shaMatchesRevision(sha, currentRevision)
}

// parseBuildTime accepts both ldflag formats: goreleaser's RFC3339
// ({{.Date}}) and the Makefile's "2006-01-02-15:04:05".
func parseBuildTime(s string) (time.Time, bool) {
for _, layout := range []string{time.RFC3339, "2006-01-02-15:04:05"} {
if t, err := time.Parse(layout, s); err == nil {
return t, true
}
// shaMatchesRevision compares the short revision baked into the binary
// (goreleaser uses {{.ShortCommit}}, 7 chars; Makefile uses full SHA)
// against the full SHA returned by GitHub. Prefix-match is enough.
func shaMatchesRevision(fullSHA, currentRevision string) bool {
n := len(currentRevision)
if n == 0 || n > len(fullSHA) {
return false
}
return strings.EqualFold(fullSHA[:n], currentRevision)
}

func fetchTagCommitSHA(ctx context.Context, tag string) (string, error) {
var c struct {
SHA string `json:"sha"`
}
url := "https://api.github.com/repos/Ehco1996/ehco/commits/" + tag
if err := getJSON(ctx, url, &c); err != nil {
return "", err
}
return time.Time{}, false
return c.SHA, nil
}

// compareVersions returns -1/0/1 like semver.Compare. Falls back to
Expand Down
49 changes: 13 additions & 36 deletions internal/updater/updater_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package updater

import (
"testing"
"time"
)

func TestResolveChannel(t *testing.T) {
Expand Down Expand Up @@ -48,45 +47,23 @@ func TestCompareVersions(t *testing.T) {
}
}

func TestParseBuildTime(t *testing.T) {
func TestShaMatchesRevision(t *testing.T) {
full := "1e0e74cabcdef0123456789abcdef0123456789a"
cases := []struct {
in string
want bool
fullSHA, revision string
want bool
}{
{"2026-05-04T23:10:16Z", true}, // goreleaser
{"2026-05-04T23:10:16+08:00", true}, // RFC3339 w/ offset
{"2026-05-04-23:10:16", true}, // Makefile
{"", false},
{"not-a-time", false},
{full, "1e0e74c", true}, // goreleaser short
{full, "1E0E74C", true}, // case-insensitive
{full, full, true}, // Makefile full SHA
{full, "deadbee", false}, // different commit
{full, "", false}, // empty -> false (caller already guards)
{full, full + "x", false}, // longer than full SHA
{"short", "shortish", false}, // revision longer than fullSHA
}
for _, c := range cases {
_, ok := parseBuildTime(c.in)
if ok != c.want {
t.Errorf("parseBuildTime(%q) ok=%v want %v", c.in, ok, c.want)
}
}
}

func TestNightlyRepublished(t *testing.T) {
built := "2026-05-04T23:10:16Z"
older := time.Date(2026, 5, 4, 22, 0, 0, 0, time.UTC)
newer := time.Date(2026, 5, 5, 6, 0, 0, 0, time.UTC)

cases := []struct {
name string
rel ghRelease
buildTime string
want bool
}{
{"prerelease republished after build", ghRelease{Prerelease: true, PublishedAt: newer}, built, true},
{"prerelease same/older than build", ghRelease{Prerelease: true, PublishedAt: older}, built, false},
{"stable release ignored", ghRelease{Prerelease: false, PublishedAt: newer}, built, false},
{"empty build time -> conservative false", ghRelease{Prerelease: true, PublishedAt: newer}, "", false},
{"unparseable build time -> false", ghRelease{Prerelease: true, PublishedAt: newer}, "garbage", false},
}
for _, c := range cases {
if got := nightlyRepublished(&c.rel, c.buildTime); got != c.want {
t.Errorf("%s: got %v want %v", c.name, got, c.want)
if got := shaMatchesRevision(c.fullSHA, c.revision); got != c.want {
t.Errorf("shaMatchesRevision(%q,%q)=%v want %v", c.fullSHA, c.revision, got, c.want)
}
}
}
6 changes: 6 additions & 0 deletions internal/web/handler_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,13 +127,19 @@ type OverviewResp struct {
Xray *glue.XraySnapshot `json:"xray,omitempty"`
Host *ms.NodeMetrics `json:"host,omitempty"`
Rules int `json:"rules"`
// LastReloadAt is the wall-clock timestamp of the most recent config
// reload attempt (file or HTTP). The freshness signal for routing
// rules / xray inbounds — distinct from boot time, which is
// surfaced on the settings page.
LastReloadAt time.Time `json:"last_reload_at,omitempty"`
}

func (s *Server) Overview(c echo.Context) error {
out := OverviewResp{}

if s.cfg != nil {
out.Rules = len(s.cfg.RelayConfigs)
out.LastReloadAt = s.cfg.LastLoadTime()
}

if p := s.xrayStatus.Load(); p != nil && *p != nil {
Expand Down
78 changes: 13 additions & 65 deletions internal/web/handler_update.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,6 @@ type VersionInfo struct {
GoArch string `json:"go_arch"`
}

// JobStatus is the in-memory record of the most-recent update attempt.
// Process-local on purpose: after a successful restart the new process
// boots with no record, the SPA reloads /version and sees the new build.
type JobStatus struct {
State updater.State `json:"state"`
Channel string `json:"channel"`
From string `json:"from"`
To string `json:"to"`
StartedAt time.Time `json:"started_at"`
Error string `json:"error,omitempty"`
}

func (s *Server) Version(c echo.Context) error {
return c.JSON(http.StatusOK, VersionInfo{
Version: constant.Version,
Expand All @@ -54,13 +42,17 @@ func (s *Server) UpdateCheck(c echo.Context) error {
}
ctx, cancel := context.WithTimeout(c.Request().Context(), 30*time.Second)
defer cancel()
res, err := updater.Check(ctx, channel, constant.Version, constant.BuildTime)
res, err := updater.Check(ctx, channel, constant.Version, constant.GitRevision)
if err != nil {
return echo.NewHTTPError(http.StatusBadGateway, err.Error())
}
return c.JSON(http.StatusOK, res)
}

// UpdateApply kicks off the update in a detached goroutine and returns
// immediately. The dashboard polls /version to detect completion (the
// running process restarts mid-flow, so any in-process state machine is
// inherently lossy). Failures are logged via s.l; check journalctl.
func (s *Server) UpdateApply(c echo.Context) error {
if runtime.GOOS != "linux" {
return echo.NewHTTPError(http.StatusBadRequest,
Expand All @@ -73,58 +65,14 @@ func (s *Server) UpdateApply(c echo.Context) error {
if opts.Channel == "" {
opts.Channel = updater.ChannelAuto
}

prev := s.updateJob.Load()
if prev != nil && isInProgress(prev.State) {
return echo.NewHTTPError(http.StatusConflict, "another update is already running")
}

job := &JobStatus{
State: updater.StateChecking,
Channel: opts.Channel,
From: constant.Version,
StartedAt: time.Now().UTC(),
}
s.updateJob.Store(job)
s.l.Infof("update apply requested channel=%s force=%v restart=%v", opts.Channel, opts.Force, opts.Restart)

// Detached context: closing the browser shouldn't abort an in-flight swap.
go s.runUpdate(opts, job)
return c.JSON(http.StatusAccepted, map[string]string{"state": string(updater.StateChecking)})
}

func (s *Server) runUpdate(opts updater.ApplyOptions, job *JobStatus) {
ctx, cancel := context.WithTimeout(context.Background(), updateApplyTimeout)
defer cancel()

onState := func(st updater.State) {
// Copy-on-write so /status readers always see a consistent snapshot.
next := *job
next.State = st
s.updateJob.Store(&next)
*job = next
}

if err := updater.Apply(ctx, opts, constant.Version, constant.BuildTime, s.l, onState); err != nil {
next := *job
next.State = updater.StateFailed
next.Error = err.Error()
s.updateJob.Store(&next)
s.l.Errorf("update failed: %v", err)
}
}

func (s *Server) UpdateStatus(c echo.Context) error {
if j := s.updateJob.Load(); j != nil {
return c.JSON(http.StatusOK, j)
}
return c.JSON(http.StatusOK, map[string]string{"state": "idle"})
}

func isInProgress(s updater.State) bool {
switch s {
case updater.StateChecking, updater.StateDownloading, updater.StateInstalling, updater.StateRestarting:
return true
}
return false
go func() {
ctx, cancel := context.WithTimeout(context.Background(), updateApplyTimeout)
defer cancel()
if err := updater.Apply(ctx, opts, constant.Version, constant.GitRevision, s.l); err != nil {
s.l.Errorf("update failed: %v", err)
}
}()
return c.NoContent(http.StatusAccepted)
}
Loading
Loading