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. 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: O update preserva:
```text ```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. 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: The update preserves:
```text ```text
+11
View File
@@ -638,3 +638,14 @@ select:disabled {
color:#f3f7ff; color:#f3f7ff;
font-size:.78rem; 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", "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" "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]))])); 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 currentLang = detectInitialLanguage();
let i18nTranslating = false; let i18nTranslating = false;
+1
View File
@@ -124,6 +124,7 @@ function initAfterLogin() {
if (currentRole === "superadmin") { if (currentRole === "superadmin") {
loadDashboardStats(); loadDashboardStats();
if (typeof loadUpdateStatus === "function") loadUpdateStatus();
statsTimer = setInterval(() => { statsTimer = setInterval(() => {
loadDashboardStats(); loadDashboardStats();
if (currentTab === "stats") loadStats(); 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); setTimeout(function(){document.documentElement.classList.remove("i18n-pending");},2500);
})(); })();
</script> </script>
<link rel="stylesheet" href="assets/app.css?v=20260511visualsafesave1"/> <link rel="stylesheet" href="assets/app.css?v=20260711updatecheck1"/>
</head> </head>
<body> <body>
<div class="app"> <div class="app">
@@ -158,6 +158,26 @@
</div> </div>
</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="grid2 dashboard-lower">
<div class="card hidden" id="dnsttDashboardCard"> <div class="card hidden" id="dnsttDashboardCard">
<div class="card-hdr"> <div class="card-hdr">
@@ -1241,15 +1261,16 @@
<!-- app.js was split into ordered modules for maintainability. They are plain <!-- app.js was split into ordered modules for maintainability. They are plain
classic scripts sharing one global scope; `defer` preserves execution order, classic scripts sharing one global scope; `defer` preserves execution order,
so behavior is identical to the old single file. Keep this load 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/01-core.js?v=20260711updatecheck1"></script>
<script defer src="assets/js/02-shell.js?v=20260704ytquicfix1"></script> <script defer src="assets/js/02-shell.js?v=20260711updatecheck1"></script>
<script defer src="assets/js/03-ssh-users.js?v=20260704ytquicfix1"></script> <script defer src="assets/js/03-ssh-users.js?v=20260711updatecheck1"></script>
<script defer src="assets/js/04-xray.js?v=20260704ytquicfix1"></script> <script defer src="assets/js/04-xray.js?v=20260711updatecheck1"></script>
<script defer src="assets/js/05-resellers.js?v=20260704ytquicfix1"></script> <script defer src="assets/js/05-resellers.js?v=20260711updatecheck1"></script>
<script defer src="assets/js/06-servers.js?v=20260704ytquicfix1"></script> <script defer src="assets/js/06-servers.js?v=20260711updatecheck1"></script>
<script defer src="assets/js/07-stats-logs.js?v=20260704ytquicfix1"></script> <script defer src="assets/js/07-stats-logs.js?v=20260711updatecheck1"></script>
<script defer src="assets/js/08-server-config.js?v=20260704ytquicfix1"></script> <script defer src="assets/js/08-server-config.js?v=20260711updatecheck1"></script>
<script defer src="assets/js/09-xray-wizard.js?v=20260704ytquicfix1"></script> <script defer src="assets/js/09-xray-wizard.js?v=20260711updatecheck1"></script>
<script defer src="assets/js/10-boot.js?v=20260704ytquicfix1"></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> </body>
</html> </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}" PANEL_LOG_MAX_BYTES="${PANEL_LOG_MAX_BYTES:-1048576}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 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")}" 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)" MKDIR_BIN="$(command -v mkdir 2>/dev/null || true)"
[[ -n "$MKDIR_BIN" ]] || MKDIR_BIN="/bin/mkdir" [[ -n "$MKDIR_BIN" ]] || MKDIR_BIN="/bin/mkdir"
# ──────────────────────────────────────────────────────────────────────────── # ────────────────────────────────────────────────────────────────────────────
@@ -264,10 +265,25 @@ info "[5/10] Building SSH Panel binary…"
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
export GOPATH=/tmp/gopath_sshpanel export GOPATH=/tmp/gopath_sshpanel
export GOCACHE=/tmp/gocache_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 download
go mod tidy 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 " Binary: $INSTALL_DIR/sshpanel"
info " Build commit: $BUILD_COMMIT ($BUILD_BRANCH)"
cp -r "$SCRIPT_DIR/admin/"* "$INSTALL_DIR/admin/" cp -r "$SCRIPT_DIR/admin/"* "$INSTALL_DIR/admin/"
info " Admin panel copied" info " Admin panel copied"
if [[ -f "$SCRIPT_DIR/update.sh" ]]; then 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/vnstat/reset", saSession(http.HandlerFunc(handleVnstatReset(store))))
mux.Handle("/api/system/logs", saSession(http.HandlerFunc(handleSystemLogs))) mux.Handle("/api/system/logs", saSession(http.HandlerFunc(handleSystemLogs)))
mux.Handle("/api/system/logs/reset", saSession(http.HandlerFunc(handleSystemLogsReset))) 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", saSession(http.HandlerFunc(handleDnsttStats)))
mux.Handle("/api/dnstt/logs", saSession(http.HandlerFunc(handleDnsttLogs))) 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)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SOURCE_DIR="" SOURCE_DIR=""
RESTART_NEEDED=false RESTART_NEEDED=false
BUILD_COMMIT=""
BUILD_BRANCH=""
BUILD_TIME=""
BUILD_REPO_URL=""
[[ $EUID -ne 0 ]] && error "Run as root: sudo bash $0" [[ $EUID -ne 0 ]] && error "Run as root: sudo bash $0"
@@ -288,9 +292,18 @@ build_binary() {
cd "$SOURCE_DIR" cd "$SOURCE_DIR"
export GOPATH=/tmp/gopath_sshpanel export GOPATH=/tmp/gopath_sshpanel
export GOCACHE=/tmp/gocache_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 download
go mod tidy 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." info " Build complete."
} }
@@ -332,6 +345,14 @@ apply_update() {
chmod 755 "$INSTALL_DIR/sshpanel" chmod 755 "$INSTALL_DIR/sshpanel"
info " Binary updated." 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/" rsync -a --delete "$SOURCE_DIR/admin/" "$INSTALL_DIR/admin/"
info " Admin panel updated." 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)
}
}