package main import ( "context" "encoding/json" "errors" "net/http" "net/url" "os" "os/exec" "runtime/debug" "strings" "sync" "time" ) const ( defaultUpdateRepoURL = "https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git" defaultUpdateBranch = "main" updateStatusCacheTTL = 5 * time.Minute updateCheckTimeout = 12 * time.Second ) // These values are injected by install.sh/update.sh with -ldflags. The // runtime/debug fallback keeps the endpoint useful for normal git builds. var ( buildCommit = "" buildBranch = "" buildTime = "" buildRepoURL = "" ) type updateStatusResponse struct { Status string `json:"status"` UpToDate bool `json:"up_to_date"` UpdateAvailable bool `json:"update_available"` LocalModified bool `json:"local_modified"` CurrentCommit string `json:"current_commit,omitempty"` CurrentCommitShort string `json:"current_commit_short,omitempty"` LatestCommit string `json:"latest_commit,omitempty"` LatestCommitShort string `json:"latest_commit_short,omitempty"` Branch string `json:"branch"` BuildTime string `json:"build_time,omitempty"` RepoURL string `json:"repo_url"` RepoWebURL string `json:"repo_web_url"` CheckedAt string `json:"checked_at"` Cached bool `json:"cached"` Error string `json:"error,omitempty"` } var updateStatusCache struct { sync.Mutex checkedAt time.Time response updateStatusResponse } func handleUpdateStatus(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") if r.Method != http.MethodGet { w.WriteHeader(http.StatusMethodNotAllowed) _ = json.NewEncoder(w).Encode(map[string]string{"error": "method not allowed"}) return } force := r.URL.Query().Get("refresh") == "1" resp := getUpdateStatus(r.Context(), force) _ = json.NewEncoder(w).Encode(resp) } func getUpdateStatus(parent context.Context, force bool) updateStatusResponse { updateStatusCache.Lock() defer updateStatusCache.Unlock() if !force && !updateStatusCache.checkedAt.IsZero() && time.Since(updateStatusCache.checkedAt) < updateStatusCacheTTL { resp := updateStatusCache.response resp.Cached = true return resp } resp := checkRemoteUpdate(parent) updateStatusCache.checkedAt = time.Now() updateStatusCache.response = resp return resp } func checkRemoteUpdate(parent context.Context) updateStatusResponse { repoURL := firstNonEmptyTrimmed( strings.TrimSpace(os.Getenv("DRAGON_UPDATE_REPO_URL")), readSingleLineFile("/opt/sshpanel/.installed_repo_url"), strings.TrimSpace(buildRepoURL), defaultUpdateRepoURL, ) branch := firstNonEmptyTrimmed( strings.TrimSpace(os.Getenv("DRAGON_UPDATE_BRANCH")), strings.TrimSpace(buildBranch), readSingleLineFile("/opt/sshpanel/.installed_branch"), defaultUpdateBranch, ) currentCommit, localModified, resolvedBuildTime := resolveCurrentBuildInfo() if resolvedBuildTime == "" { resolvedBuildTime = readSingleLineFile("/opt/sshpanel/.installed_build_time") } if currentCommit == "" { currentCommit = normalizeGitCommit(readSingleLineFile("/opt/sshpanel/.installed_commit")) } now := time.Now().UTC().Format(time.RFC3339) resp := updateStatusResponse{ Status: "unknown", CurrentCommit: currentCommit, CurrentCommitShort: shortCommit(currentCommit), Branch: branch, BuildTime: resolvedBuildTime, RepoURL: safeRepoURL(repoURL), RepoWebURL: repoWebURL(repoURL), CheckedAt: now, LocalModified: localModified, } ctx, cancel := context.WithTimeout(parent, updateCheckTimeout) defer cancel() latestCommit, err := queryRemoteCommit(ctx, repoURL, branch) if err != nil { if errors.Is(ctx.Err(), context.DeadlineExceeded) { resp.Error = "remote update check timed out" } else { resp.Error = "could not read the remote Git branch" } return resp } resp.LatestCommit = latestCommit resp.LatestCommitShort = shortCommit(latestCommit) if currentCommit == "" { resp.Error = "current build commit is unavailable" return resp } resp.Status, resp.UpToDate, resp.UpdateAvailable = classifyUpdateStatus(currentCommit, latestCommit, localModified) return resp } func classifyUpdateStatus(currentCommit, latestCommit string, localModified bool) (status string, upToDate bool, updateAvailable bool) { if currentCommit == "" || latestCommit == "" { return "unknown", false, false } if strings.EqualFold(currentCommit, latestCommit) { if localModified { return "local_changes", false, false } return "up_to_date", true, false } return "update_available", false, true } func queryRemoteCommit(ctx context.Context, repoURL, branch string) (string, error) { cmd := exec.CommandContext(ctx, "git", "ls-remote", "--exit-code", "--heads", repoURL, "refs/heads/"+branch) cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=/bin/false", ) out, err := cmd.Output() if err != nil { return "", err } fields := strings.Fields(string(out)) if len(fields) < 2 { return "", errors.New("invalid git ls-remote response") } commit := normalizeGitCommit(fields[0]) if commit == "" { return "", errors.New("invalid remote commit") } return commit, nil } func resolveCurrentBuildInfo() (commit string, modified bool, builtAt string) { commit = normalizeGitCommit(buildCommit) builtAt = strings.TrimSpace(buildTime) info, ok := debug.ReadBuildInfo() if !ok { return commit, false, builtAt } for _, setting := range info.Settings { switch setting.Key { case "vcs.revision": if commit == "" { commit = normalizeGitCommit(setting.Value) } case "vcs.modified": modified = strings.EqualFold(setting.Value, "true") case "vcs.time": if builtAt == "" { builtAt = strings.TrimSpace(setting.Value) } } } return commit, modified, builtAt } func normalizeGitCommit(value string) string { value = strings.TrimSpace(value) if strings.EqualFold(value, "unknown") || len(value) < 7 || len(value) > 64 { return "" } for _, r := range value { if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) { return "" } } return strings.ToLower(value) } func shortCommit(commit string) string { if len(commit) <= 12 { return commit } return commit[:12] } func readSingleLineFile(path string) string { data, err := os.ReadFile(path) if err != nil { return "" } line := strings.TrimSpace(string(data)) if idx := strings.IndexByte(line, '\n'); idx >= 0 { line = strings.TrimSpace(line[:idx]) } return line } func firstNonEmptyTrimmed(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" } func safeRepoURL(raw string) string { u, err := url.Parse(strings.TrimSpace(raw)) if err != nil { return strings.TrimSpace(raw) } u.User = nil return u.String() } func repoWebURL(raw string) string { value := safeRepoURL(raw) return strings.TrimSuffix(value, ".git") }