Check Update

This commit is contained in:
2026-07-11 02:04:36 -03:00
parent 6f7fa2fad1
commit aea27916e8
11 changed files with 569 additions and 13 deletions
+18
View File
@@ -286,6 +286,15 @@ https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git
Depois ele recompila o binário e atualiza o painel web e os scripts auxiliares, mantendo as configurações e dados existentes.
O painel do superadmin também mostra um cartão **Atualizações do painel** na tela inicial. Ele compara o commit compilado no servidor com o commit mais recente da branch configurada no Git. A verificação usa cache de 5 minutos; o botão **Verificar agora** força uma nova consulta.
Variáveis opcionais do serviço para apontar a verificação para outro Git/branch:
```text
DRAGON_UPDATE_REPO_URL=https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git
DRAGON_UPDATE_BRANCH=main
```
O update preserva:
```text
@@ -788,6 +797,15 @@ https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git
Then it rebuilds the binary and updates the web panel and helper scripts while keeping existing configuration and user data.
The superadmin dashboard also shows a **Panel Updates** card. It compares the commit compiled into the running server with the latest commit on the configured Git branch. Results are cached for 5 minutes; **Check now** forces a fresh lookup.
Optional service variables for a different repository/branch:
```text
DRAGON_UPDATE_REPO_URL=https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git
DRAGON_UPDATE_BRANCH=main
```
The update preserves:
```text
+11
View File
@@ -638,3 +638,14 @@ select:disabled {
color:#f3f7ff;
font-size:.78rem;
}
/* Git update status card */
.update-commit{
font-family:ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",monospace;
font-size:1rem!important;
letter-spacing:.01em!important;
overflow-wrap:anywhere;
}
.update-check-time{font-size:.9rem!important;letter-spacing:0!important;}
.update-statusbar{align-items:center;gap:12px;flex-wrap:wrap;}
.btn-xs{padding:5px 8px!important;font-size:.67rem!important;border-radius:9px!important;}
+16
View File
@@ -96,6 +96,22 @@ Object.assign(I18N_ALIASES, {
"Public Key — share with dnstt clients":"Public Key — share with dnstt clients","Chave pública — compartilhe com clientes DNSTT":"Public Key — share with dnstt clients",
"Máx. sessões UDP por cliente":"Max UDP Sessions Per Client","(não é o total de usuários do servidor)":"(not total server users)","Nome do serviço":"Service Name","Modo":"Mode","Protocolo":"Protocol","Porta":"Port","IP de listen":"Listen IP","Método":"Method","Caminho":"Path","Destino":"Dest","ID curto":"Short ID","Nome do servidor":"Server Name","Caminho do arquivo cert":"Cert File Path","Caminho do arquivo key":"Key File Path","Fonte do certificado:":"Certificate source:","Autoassinado":"Self-Signed","Colar PEM":"Paste PEM","Caminho do arquivo":"File Path","Salvar PEM":"Save PEM","Intervalo de reinício automático":"Auto Restart Interval","Atraso para reiniciar":"Restart Grace Delay","0s/off desativa":"0s/off disables","Chave pública":"Public Key","Nome do domínio":"Domain Name"
});
Object.assign(I18N_TEXT["en-US"], {
"Panel Updates":"Panel Updates","Checking…":"Checking…","Open Git":"Open Git","Check now":"Check now","Installed version":"Installed version","Latest Git version":"Latest Git version","Branch":"Branch","Last checked":"Last checked",
"Comparing the installed version with the Git repository.":"Comparing the installed version with the Git repository.","To update:":"To update:","Copy command":"Copy command","Up to date":"Up to date","Update available":"Update available","Local changes":"Local changes","Unknown":"Unknown",
"The installed version matches the latest commit on {branch}.":"The installed version matches the latest commit on {branch}.","A newer commit is available on {branch}.":"A newer commit is available on {branch}.","This build contains local changes, so it cannot be compared safely.":"This build contains local changes, so it cannot be compared safely.",
"Could not check for updates: {error}":"Could not check for updates: {error}","The update status could not be determined.":"The update status could not be determined.","Checking repository…":"Checking repository…","Update check timed out.":"Update check timed out.","Could not reach the Git repository.":"Could not reach the Git repository.","Current build commit is unavailable.":"Current build commit is unavailable.","Unknown update-check error.":"Unknown update-check error.","Copied":"Copied"
});
Object.assign(I18N_TEXT["pt-BR"], {
"Panel Updates":"Atualizações do painel","Checking…":"Verificando…","Open Git":"Abrir Git","Check now":"Verificar agora","Installed version":"Versão instalada","Latest Git version":"Última versão no Git","Branch":"Branch","Last checked":"Última verificação",
"Comparing the installed version with the Git repository.":"Comparando a versão instalada com o repositório Git.","To update:":"Para atualizar:","Copy command":"Copiar comando","Up to date":"Atualizado","Update available":"Atualização disponível","Local changes":"Alterações locais","Unknown":"Desconhecido",
"The installed version matches the latest commit on {branch}.":"A versão instalada corresponde ao commit mais recente da branch {branch}.","A newer commit is available on {branch}.":"Existe um commit mais recente disponível na branch {branch}.","This build contains local changes, so it cannot be compared safely.":"Esta compilação contém alterações locais e não pode ser comparada com segurança.",
"Could not check for updates: {error}":"Não foi possível verificar atualizações: {error}","The update status could not be determined.":"Não foi possível determinar o status da atualização.","Checking repository…":"Verificando o repositório…","Update check timed out.":"A verificação de atualização excedeu o tempo limite.","Could not reach the Git repository.":"Não foi possível acessar o repositório Git.","Current build commit is unavailable.":"O commit da compilação atual não está disponível.","Unknown update-check error.":"Erro desconhecido ao verificar atualização.","Copied":"Copiado"
});
Object.assign(I18N_ALIASES, {
"Atualizações do painel":"Panel Updates","Verificando…":"Checking…","Abrir Git":"Open Git","Verificar agora":"Check now","Versão instalada":"Installed version","Última versão no Git":"Latest Git version","Última verificação":"Last checked",
"Comparando a versão instalada com o repositório Git.":"Comparing the installed version with the Git repository.","Para atualizar:":"To update:","Copiar comando":"Copy command","Atualizado":"Up to date","Atualização disponível":"Update available","Alterações locais":"Local changes","Desconhecido":"Unknown"
});
const I18N_REVERSE = Object.fromEntries(SUPPORTED_LANGS.map(lang => [lang, Object.fromEntries(Object.entries(I18N_TEXT[lang] || {}).map(([k, v]) => [v, k]))]));
let currentLang = detectInitialLanguage();
let i18nTranslating = false;
+1
View File
@@ -124,6 +124,7 @@ function initAfterLogin() {
if (currentRole === "superadmin") {
loadDashboardStats();
if (typeof loadUpdateStatus === "function") loadUpdateStatus();
statsTimer = setInterval(() => {
loadDashboardStats();
if (currentTab === "stats") loadStats();
+119
View File
@@ -0,0 +1,119 @@
// ─── Git update status ───────────────────────────────────────────────────────
const DRAGON_UPDATE_COMMAND = "sudo bash /opt/sshpanel/update.sh";
function updateStatusErrorText(error) {
switch (String(error || "")) {
case "remote update check timed out":
return t("Update check timed out.");
case "could not read the remote Git branch":
return t("Could not reach the Git repository.");
case "current build commit is unavailable":
return t("Current build commit is unavailable.");
default:
return error || t("Unknown update-check error.");
}
}
function setUpdateState(label, tone = "") {
const chip = document.getElementById("updateStateChip");
if (!chip) return;
chip.className = "chip" + (tone ? ` ${tone}` : "");
chip.textContent = label;
}
function setCommitValue(elementID, shortValue, fullValue) {
const el = document.getElementById(elementID);
if (!el) return;
el.textContent = shortValue || "--";
el.title = fullValue || "";
}
function renderUpdateStatus(data) {
setCommitValue("updateCurrentCommit", data.current_commit_short, data.current_commit);
setCommitValue("updateLatestCommit", data.latest_commit_short, data.latest_commit);
const branch = document.getElementById("updateBranch");
if (branch) branch.textContent = data.branch || "main";
const checked = document.getElementById("updateCheckedAt");
if (checked) {
const date = data.checked_at ? new Date(data.checked_at) : null;
checked.textContent = date && Number.isFinite(date.getTime()) ? date.toLocaleString() : "--";
}
const repoLink = document.getElementById("updateRepoLink");
if (repoLink && data.repo_web_url) repoLink.href = data.repo_web_url;
const text = document.getElementById("updateStatusText");
const commandWrap = document.getElementById("updateCommandWrap");
commandWrap?.classList.toggle("hidden", !data.update_available);
if (data.status === "up_to_date") {
setUpdateState(t("Up to date"), "green");
if (text) text.textContent = t("The installed version matches the latest commit on {branch}.", { branch: data.branch || "main" });
return;
}
if (data.status === "update_available") {
setUpdateState(t("Update available"), "warn");
if (text) text.textContent = t("A newer commit is available on {branch}.", { branch: data.branch || "main" });
return;
}
if (data.status === "local_changes") {
setUpdateState(t("Local changes"), "warn");
if (text) text.textContent = t("This build contains local changes, so it cannot be compared safely.");
return;
}
setUpdateState(t("Unknown"), "red");
if (text) {
text.textContent = data.error
? t("Could not check for updates: {error}", { error: updateStatusErrorText(data.error) })
: t("The update status could not be determined.");
}
}
async function loadUpdateStatus(force = false) {
if (currentRole !== "superadmin") return;
const button = document.getElementById("checkUpdateBtn");
const text = document.getElementById("updateStatusText");
if (button) button.disabled = true;
setUpdateState(t("Checking…"));
if (text) text.textContent = t("Checking repository…");
try {
const path = "/api/system/update-status" + (force ? "?refresh=1" : "");
const res = await api(path);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
renderUpdateStatus(await res.json());
} catch (error) {
setUpdateState(t("Unknown"), "red");
if (text) text.textContent = t("Could not check for updates: {error}", { error: error.message || t("Network error.") });
} finally {
if (button) button.disabled = false;
}
}
document.getElementById("checkUpdateBtn")?.addEventListener("click", () => loadUpdateStatus(true));
document.getElementById("copyUpdateCommandBtn")?.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(DRAGON_UPDATE_COMMAND);
} catch {
const input = document.createElement("textarea");
input.value = DRAGON_UPDATE_COMMAND;
input.style.position = "fixed";
input.style.opacity = "0";
document.body.appendChild(input);
input.select();
document.execCommand("copy");
input.remove();
}
const button = document.getElementById("copyUpdateCommandBtn");
if (button) {
const oldText = button.textContent;
button.textContent = t("Copied");
setTimeout(() => { button.textContent = oldText; }, 1400);
}
});
+32 -11
View File
@@ -16,7 +16,7 @@
setTimeout(function(){document.documentElement.classList.remove("i18n-pending");},2500);
})();
</script>
<link rel="stylesheet" href="assets/app.css?v=20260511visualsafesave1"/>
<link rel="stylesheet" href="assets/app.css?v=20260711updatecheck1"/>
</head>
<body>
<div class="app">
@@ -158,6 +158,26 @@
</div>
</div>
<div class="card superadmin-only hidden" id="updateStatusCard" style="margin-top:18px;">
<div class="card-hdr">
<div class="card-title">Panel Updates <span class="chip" id="updateStateChip">Checking…</span></div>
<div class="card-actions">
<a class="btn btn-ghost btn-sm" id="updateRepoLink" href="https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB" target="_blank" rel="noopener noreferrer">Open Git</a>
<button class="btn btn-ghost btn-sm" type="button" id="checkUpdateBtn">Check now</button>
</div>
</div>
<div class="metrics">
<div class="metric"><div class="m-label">Installed version</div><div class="m-val update-commit" id="updateCurrentCommit">--</div></div>
<div class="metric"><div class="m-label">Latest Git version</div><div class="m-val update-commit" id="updateLatestCommit">--</div></div>
<div class="metric"><div class="m-label">Branch</div><div class="m-val update-commit" id="updateBranch">main</div></div>
<div class="metric"><div class="m-label">Last checked</div><div class="m-val update-check-time" id="updateCheckedAt">--</div></div>
</div>
<div class="statusbar update-statusbar">
<span id="updateStatusText">Comparing the installed version with the Git repository.</span>
<span class="hint hidden" id="updateCommandWrap">To update: <code>sudo bash /opt/sshpanel/update.sh</code> <button class="btn btn-ghost btn-xs" type="button" id="copyUpdateCommandBtn">Copy command</button></span>
</div>
</div>
<div class="grid2 dashboard-lower">
<div class="card hidden" id="dnsttDashboardCard">
<div class="card-hdr">
@@ -1241,15 +1261,16 @@
<!-- app.js was split into ordered modules for maintainability. They are plain
classic scripts sharing one global scope; `defer` preserves execution order,
so behavior is identical to the old single file. Keep this load order. -->
<script defer src="assets/js/01-core.js?v=20260704ytquicfix1"></script>
<script defer src="assets/js/02-shell.js?v=20260704ytquicfix1"></script>
<script defer src="assets/js/03-ssh-users.js?v=20260704ytquicfix1"></script>
<script defer src="assets/js/04-xray.js?v=20260704ytquicfix1"></script>
<script defer src="assets/js/05-resellers.js?v=20260704ytquicfix1"></script>
<script defer src="assets/js/06-servers.js?v=20260704ytquicfix1"></script>
<script defer src="assets/js/07-stats-logs.js?v=20260704ytquicfix1"></script>
<script defer src="assets/js/08-server-config.js?v=20260704ytquicfix1"></script>
<script defer src="assets/js/09-xray-wizard.js?v=20260704ytquicfix1"></script>
<script defer src="assets/js/10-boot.js?v=20260704ytquicfix1"></script>
<script defer src="assets/js/01-core.js?v=20260711updatecheck1"></script>
<script defer src="assets/js/02-shell.js?v=20260711updatecheck1"></script>
<script defer src="assets/js/03-ssh-users.js?v=20260711updatecheck1"></script>
<script defer src="assets/js/04-xray.js?v=20260711updatecheck1"></script>
<script defer src="assets/js/05-resellers.js?v=20260711updatecheck1"></script>
<script defer src="assets/js/06-servers.js?v=20260711updatecheck1"></script>
<script defer src="assets/js/07-stats-logs.js?v=20260711updatecheck1"></script>
<script defer src="assets/js/08-server-config.js?v=20260711updatecheck1"></script>
<script defer src="assets/js/09-xray-wizard.js?v=20260711updatecheck1"></script>
<script defer src="assets/js/11-update-status.js?v=20260711updatecheck1"></script>
<script defer src="assets/js/10-boot.js?v=20260711updatecheck1"></script>
</body>
</html>
+17 -1
View File
@@ -15,6 +15,7 @@ LOG_TMPFS_SIZE="${LOG_TMPFS_SIZE:-15m}"
PANEL_LOG_MAX_BYTES="${PANEL_LOG_MAX_BYTES:-1048576}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
GO_VERSION="${GO_VERSION:-$(awk '$1 == "go" {print $2; exit}' "$SCRIPT_DIR/go.mod" 2>/dev/null || echo "1.22.5")}"
REPO_URL="${REPO_URL:-https://git.dr2.site/penguinehis/DragonCoreSSH-NewWEB.git}"
MKDIR_BIN="$(command -v mkdir 2>/dev/null || true)"
[[ -n "$MKDIR_BIN" ]] || MKDIR_BIN="/bin/mkdir"
# ────────────────────────────────────────────────────────────────────────────
@@ -264,10 +265,25 @@ info "[5/10] Building SSH Panel binary…"
cd "$SCRIPT_DIR"
export GOPATH=/tmp/gopath_sshpanel
export GOCACHE=/tmp/gocache_sshpanel
BUILD_COMMIT="$(git -C "$SCRIPT_DIR" rev-parse HEAD 2>/dev/null || true)"
BUILD_BRANCH="$(git -C "$SCRIPT_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
BUILD_REPO_URL="$(git -C "$SCRIPT_DIR" config --get remote.origin.url 2>/dev/null || true)"
[[ -n "$BUILD_COMMIT" ]] || BUILD_COMMIT="unknown"
[[ -n "$BUILD_BRANCH" && "$BUILD_BRANCH" != "HEAD" ]] || BUILD_BRANCH="main"
[[ -n "$BUILD_REPO_URL" ]] || BUILD_REPO_URL="$REPO_URL"
go mod download
go mod tidy
go build -ldflags="-s -w" -o "$INSTALL_DIR/sshpanel" .
go build -ldflags="-s -w -X main.buildCommit=$BUILD_COMMIT -X main.buildBranch=$BUILD_BRANCH -X main.buildTime=$BUILD_TIME" -o "$INSTALL_DIR/sshpanel" .
printf '%s\n' "$BUILD_COMMIT" > "$INSTALL_DIR/.installed_commit"
printf '%s\n' "$BUILD_BRANCH" > "$INSTALL_DIR/.installed_branch"
printf '%s\n' "$BUILD_TIME" > "$INSTALL_DIR/.installed_build_time"
printf '%s\n' "$BUILD_REPO_URL" > "$INSTALL_DIR/.installed_repo_url"
chmod 0644 "$INSTALL_DIR/.installed_commit" "$INSTALL_DIR/.installed_branch" "$INSTALL_DIR/.installed_build_time"
chmod 0600 "$INSTALL_DIR/.installed_repo_url"
info " Binary: $INSTALL_DIR/sshpanel"
info " Build commit: $BUILD_COMMIT ($BUILD_BRANCH)"
cp -r "$SCRIPT_DIR/admin/"* "$INSTALL_DIR/admin/"
info " Admin panel copied"
if [[ -f "$SCRIPT_DIR/update.sh" ]]; then
+1
View File
@@ -1567,6 +1567,7 @@ func startAdminAPI(store *Store, addr string, adminDir string) {
mux.Handle("/api/vnstat/reset", saSession(http.HandlerFunc(handleVnstatReset(store))))
mux.Handle("/api/system/logs", saSession(http.HandlerFunc(handleSystemLogs)))
mux.Handle("/api/system/logs/reset", saSession(http.HandlerFunc(handleSystemLogsReset)))
mux.Handle("/api/system/update-status", saSession(http.HandlerFunc(handleUpdateStatus)))
mux.Handle("/api/dnstt", saSession(http.HandlerFunc(handleDnsttStats)))
mux.Handle("/api/dnstt/logs", saSession(http.HandlerFunc(handleDnsttLogs)))
+22 -1
View File
@@ -36,6 +36,10 @@ MKDIR_BIN="$(command -v mkdir 2>/dev/null || true)"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SOURCE_DIR=""
RESTART_NEEDED=false
BUILD_COMMIT=""
BUILD_BRANCH=""
BUILD_TIME=""
BUILD_REPO_URL=""
[[ $EUID -ne 0 ]] && error "Run as root: sudo bash $0"
@@ -288,9 +292,18 @@ build_binary() {
cd "$SOURCE_DIR"
export GOPATH=/tmp/gopath_sshpanel
export GOCACHE=/tmp/gocache_sshpanel
BUILD_COMMIT="$(git -C "$SOURCE_DIR" rev-parse HEAD 2>/dev/null || true)"
BUILD_BRANCH="${UPDATE_REF:-$(git -C "$SOURCE_DIR" rev-parse --abbrev-ref HEAD 2>/dev/null || true)}"
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
BUILD_REPO_URL="$REPO_URL"
[[ -n "$BUILD_COMMIT" ]] || BUILD_COMMIT="unknown"
[[ -n "$BUILD_BRANCH" && "$BUILD_BRANCH" != "HEAD" ]] || BUILD_BRANCH="main"
go mod download
go mod tidy
go build -ldflags="-s -w" -o /tmp/sshpanel_new .
go build -ldflags="-s -w -X main.buildCommit=$BUILD_COMMIT -X main.buildBranch=$BUILD_BRANCH -X main.buildTime=$BUILD_TIME" -o /tmp/sshpanel_new .
info " Build commit: $BUILD_COMMIT ($BUILD_BRANCH)"
info " Build complete."
}
@@ -332,6 +345,14 @@ apply_update() {
chmod 755 "$INSTALL_DIR/sshpanel"
info " Binary updated."
printf '%s\n' "$BUILD_COMMIT" > "$INSTALL_DIR/.installed_commit"
printf '%s\n' "$BUILD_BRANCH" > "$INSTALL_DIR/.installed_branch"
printf '%s\n' "$BUILD_TIME" > "$INSTALL_DIR/.installed_build_time"
printf '%s\n' "$BUILD_REPO_URL" > "$INSTALL_DIR/.installed_repo_url"
chmod 0644 "$INSTALL_DIR/.installed_commit" "$INSTALL_DIR/.installed_branch" "$INSTALL_DIR/.installed_build_time"
chmod 0600 "$INSTALL_DIR/.installed_repo_url"
info " Build metadata updated."
rsync -a --delete "$SOURCE_DIR/admin/" "$INSTALL_DIR/admin/"
info " Admin panel updated."
+259
View File
@@ -0,0 +1,259 @@
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 := firstNonEmpty(
strings.TrimSpace(os.Getenv("DRAGON_UPDATE_REPO_URL")),
readSingleLineFile("/opt/sshpanel/.installed_repo_url"),
strings.TrimSpace(buildRepoURL),
defaultUpdateRepoURL,
)
branch := firstNonEmpty(
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 firstNonEmpty(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")
}
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"context"
"os"
"path/filepath"
"runtime"
"testing"
)
func TestNormalizeGitCommit(t *testing.T) {
valid := "CF49340B9A1234567890ABCDEF1234567890ABCD"
got := normalizeGitCommit(valid)
want := "cf49340b9a1234567890abcdef1234567890abcd"
if got != want {
t.Fatalf("normalizeGitCommit() = %q, want %q", got, want)
}
for _, value := range []string{"", "unknown", "xyz1234", "123 4567", "123456"} {
if got := normalizeGitCommit(value); got != "" {
t.Fatalf("normalizeGitCommit(%q) = %q, want empty", value, got)
}
}
}
func TestClassifyUpdateStatus(t *testing.T) {
const current = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
const latest = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
status, upToDate, updateAvailable := classifyUpdateStatus(current, current, false)
if status != "up_to_date" || !upToDate || updateAvailable {
t.Fatalf("same clean commit classified as %q, upToDate=%v updateAvailable=%v", status, upToDate, updateAvailable)
}
status, upToDate, updateAvailable = classifyUpdateStatus(current, current, true)
if status != "local_changes" || upToDate || updateAvailable {
t.Fatalf("modified commit classified as %q, upToDate=%v updateAvailable=%v", status, upToDate, updateAvailable)
}
status, upToDate, updateAvailable = classifyUpdateStatus(current, latest, false)
if status != "update_available" || upToDate || !updateAvailable {
t.Fatalf("different commit classified as %q, upToDate=%v updateAvailable=%v", status, upToDate, updateAvailable)
}
}
func TestRepoWebURLRemovesCredentialsAndGitSuffix(t *testing.T) {
got := repoWebURL("https://user:secret@git.example.test/owner/repo.git")
want := "https://git.example.test/owner/repo"
if got != want {
t.Fatalf("repoWebURL() = %q, want %q", got, want)
}
}
func TestQueryRemoteCommit(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell-script git stub is Unix-specific")
}
const want = "cf49340b9a1234567890abcdef1234567890abcd"
dir := t.TempDir()
gitPath := filepath.Join(dir, "git")
script := "#!/bin/sh\nprintf '%s\\trefs/heads/main\\n' '" + want + "'\n"
if err := os.WriteFile(gitPath, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
got, err := queryRemoteCommit(context.Background(), "https://git.example.test/owner/repo.git", "main")
if err != nil {
t.Fatalf("queryRemoteCommit() error = %v", err)
}
if got != want {
t.Fatalf("queryRemoteCommit() = %q, want %q", got, want)
}
}