diff --git a/agent/app/core/downloader.py b/agent/app/core/downloader.py index 4bf8cd8..6793ca9 100644 --- a/agent/app/core/downloader.py +++ b/agent/app/core/downloader.py @@ -33,6 +33,7 @@ class Downloader: self.repository.add_log(self.task_id, "info", f"Downloading {url}") with httpx.stream("GET", url, follow_redirects=True, timeout=120) as response: response.raise_for_status() + self._validate_response(url, response) with destination.open("wb") as handle: for chunk in response.iter_bytes(): handle.write(chunk) @@ -40,3 +41,13 @@ class Downloader: self.repository.add_log(self.task_id, "info", f"Downloaded to {destination}") return destination + def _validate_response(self, requested_url: str, response: httpx.Response) -> None: + final_url = str(response.url) + validate_url_host(final_url, settings.allowed_download_hosts) + + content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower() + if content_type in {"text/html", "text/plain"}: + raise ValueError( + "download did not return a package file " + f"(requested {requested_url}, final {final_url}, content-type {content_type or 'unknown'})" + ) diff --git a/agent/app/core/task_runner.py b/agent/app/core/task_runner.py index 7807c09..15028e6 100644 --- a/agent/app/core/task_runner.py +++ b/agent/app/core/task_runner.py @@ -6,7 +6,7 @@ import traceback from typing import Any from app.config import settings -from app.core.checksum import verify_sha256 +from app.core.checksum import sha256_file from app.core.command_runner import CommandRunner from app.core.downloader import Downloader from app.core.installer import DebInstaller @@ -197,8 +197,12 @@ class TaskRunner: package_path = downloader.download(component["downloadUrl"]) self.repository.update_task_component(task_id, component_id, progress=35, current_step="verifying checksum") - if not verify_sha256(package_path, component["sha256"]): - raise ValueError(f"Checksum mismatch for {component_id}") + actual_sha256 = sha256_file(package_path) + expected_sha256 = component["sha256"].lower() + if actual_sha256.lower() != expected_sha256: + raise ValueError( + f"Checksum mismatch for {component_id}: expected {expected_sha256}, got {actual_sha256}" + ) self.repository.add_log(task_id, "info", f"Checksum verified for {component_id}") self.repository.update_task_component(task_id, component_id, progress=60, current_step="installing package") @@ -245,4 +249,3 @@ class TaskRunner: geteuid = getattr(os, "geteuid", None) if callable(geteuid) and geteuid() != 0: raise PermissionError("Agent must run as root to call apt and systemctl") - diff --git a/agent/packaging/DEBIAN/control b/agent/packaging/DEBIAN/control index 5cf1599..cb0b739 100644 --- a/agent/packaging/DEBIAN/control +++ b/agent/packaging/DEBIAN/control @@ -1,5 +1,5 @@ Package: local-installer-agent -Version: 0.1.0 +Version: 0.1.3 Section: utils Priority: optional Architecture: amd64 diff --git a/agent/scripts/build-deb.sh b/agent/scripts/build-deb.sh index b0df65c..bd1103f 100644 --- a/agent/scripts/build-deb.sh +++ b/agent/scripts/build-deb.sh @@ -1,11 +1,22 @@ #!/usr/bin/env bash set -euo pipefail -VERSION="${VERSION:-0.1.0}" +VERSION="${VERSION:-0.1.3}" ARCH="${ARCH:-amd64}" PKG_NAME="local-installer-agent" -BUILD_ROOT="build" +BUILD_ROOT="${BUILD_ROOT:-build}" BUILD_DIR="${BUILD_ROOT}/${PKG_NAME}_${VERSION}_${ARCH}" +OUTPUT_PACKAGE="${BUILD_DIR}.deb" + +if [[ ! "$VERSION" =~ ^[a-zA-Z0-9][a-zA-Z0-9._:+~=-]*$ ]]; then + echo "Invalid VERSION: ${VERSION}" >&2 + exit 1 +fi + +if [[ ! "$ARCH" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then + echo "Invalid ARCH: ${ARCH}" >&2 + exit 1 +fi rm -rf "${BUILD_ROOT}" @@ -25,9 +36,15 @@ cp packaging/DEBIAN/postinst "${BUILD_DIR}/DEBIAN/postinst" cp packaging/DEBIAN/prerm "${BUILD_DIR}/DEBIAN/prerm" cp packaging/DEBIAN/postrm "${BUILD_DIR}/DEBIAN/postrm" +sed -i \ + -e "s/^Version:.*/Version: ${VERSION}/" \ + -e "s/^Architecture:.*/Architecture: ${ARCH}/" \ + "${BUILD_DIR}/DEBIAN/control" + chmod 755 "${BUILD_DIR}/DEBIAN/postinst" chmod 755 "${BUILD_DIR}/DEBIAN/prerm" chmod 755 "${BUILD_DIR}/DEBIAN/postrm" +chmod 755 "${BUILD_DIR}/DEBIAN" cat > "${BUILD_DIR}/etc/local-installer-agent/agent.env" < { return new Map(installedApps.map((app) => [app.appId, app])); @@ -97,7 +170,7 @@ function App() { const mergedApps = useMemo(() => { return apps.map((app) => { - const installed = installedByAppId.get(app.appId); + const installed = installedByAppId.get(app.appId) || installedByAppId.get(app.appCode); const isInstalled = Boolean(installed); const canUpdate = Boolean(isInstalled && installed.version && installed.version !== app.version); @@ -115,6 +188,7 @@ function App() { return mergedApps.filter((app) => { const matchesQuery = !needle || [ app.appId, + app.appCode, app.appName, app.version, app.status @@ -132,6 +206,14 @@ function App() { return mergedApps.find((app) => app.appId === selectedAppId) || mergedApps[0] || null; }, [mergedApps, selectedAppId]); + const agentNeedsUpdate = useMemo(() => { + return Boolean( + agentHealth?.agentVersion + && latestAgentPackage?.version + && compareAgentVersions(agentHealth.agentVersion, latestAgentPackage.version) < 0 + ); + }, [agentHealth?.agentVersion, latestAgentPackage?.version]); + const stats = useMemo(() => { return { available: apps.length, @@ -148,13 +230,19 @@ function App() { const refreshPackage = useCallback(async () => { setPackageStatus({ state: 'loading', message: 'Đang tải app từ package server' }); try { - const nextApps = await fetchPackageApps(packageBaseUrl); + const [nextApps, nextAgentPackage] = await Promise.all([ + fetchPackageApps(packageBaseUrl), + fetchLatestAgentPackage(packageBaseUrl).catch(() => null) + ]); setApps(nextApps); - setPackageStatus({ state: 'success', message: `${nextApps.length} app released` }); + setLatestAgentPackage(nextAgentPackage); + const agentNote = nextAgentPackage?.version ? ` · Agent ${nextAgentPackage.version}` : ''; + setPackageStatus({ state: 'success', message: `${nextApps.length} app released${agentNote}` }); return nextApps; } catch (error) { setPackageStatus({ state: 'danger', message: getErrorMessage(error) }); setApps([]); + setLatestAgentPackage(null); return []; } }, [packageBaseUrl]); @@ -211,24 +299,57 @@ function App() { const loadTaskSnapshot = useCallback(async (taskId) => { try { - const nextTask = await fetchTaskStatus(agentBaseUrl, taskId); + const [nextTask, logs, components] = await Promise.all([ + fetchTaskStatus(agentBaseUrl, taskId), + fetchTaskLogs(agentBaseUrl, taskId).catch(() => []), + fetchTaskComponents(agentBaseUrl, taskId).catch(() => []) + ]); + const snapshot = { + ...nextTask, + logs, + components, + pollError: '' + }; - if (TERMINAL_TASK_STATUSES.has(nextTask.status)) { + setActiveTask((current) => { + if (!current || current.taskId !== taskId) return current; + return { ...current, ...snapshot }; + }); + + if (TERMINAL_TASK_STATUSES.has(snapshot.status)) { await refreshAgent(); } - return nextTask; - } catch { + return snapshot; + } catch (error) { + const message = getErrorMessage(error); + setActiveTask((current) => { + if (!current || current.taskId !== taskId) return current; + return { ...current, pollError: message }; + }); return null; } }, [agentBaseUrl, refreshAgent]); const startTask = useCallback((queuedTask, action, app) => { + const queuedAt = new Date().toISOString(); setActiveTask({ taskId: queuedTask.taskId, action, appId: app.appId, appName: app.appName, - queuedAt: new Date().toISOString() + status: queuedTask.status || 'queued', + progress: 0, + currentStep: 'queued', + logs: [ + { + time: queuedAt, + level: 'info', + message: `Task ${queuedTask.taskId} queued` + } + ], + components: [], + pollError: '', + queuedAt }); }, []); @@ -274,13 +395,24 @@ function App() { }, [draftSettings, notify]); const copyInstallCommand = useCallback(async () => { + if (!navigator.clipboard?.writeText) { + if (copyTextFallback(agentCommand)) { + notify('success', agentNeedsUpdate ? 'Da copy lenh update Agent' : 'Da copy lenh cai Agent'); + return; + } + + window.prompt('Copy Agent command', agentCommand); + notify('warning', 'Browser dang chan copy tu dong. Hay copy lenh trong popup.'); + return; + } + try { - await navigator.clipboard.writeText(installCommand); - notify('success', 'Đã copy lệnh cài Agent'); + await navigator.clipboard.writeText(agentCommand); + notify('success', agentNeedsUpdate ? 'Đã copy lệnh update Agent' : 'Đã copy lệnh cài Agent'); } catch { notify('warning', 'Không thể copy tự động trong browser này'); } - }, [installCommand, notify]); + }, [agentCommand, agentNeedsUpdate, notify]); useEffect(() => { refreshAll(); @@ -297,14 +429,25 @@ function App() { }, [loadSelectedDetail, selectedApp?.appId]); useEffect(() => { - if (!activeTask?.taskId) return undefined; + if (!activeTask?.taskId || TERMINAL_TASK_STATUSES.has(activeTask.status)) return undefined; let disposed = false; + let terminalNotified = false; async function poll() { const nextTask = await loadTaskSnapshot(activeTask.taskId); if (disposed || !nextTask) return; if (TERMINAL_TASK_STATUSES.has(nextTask.status)) { window.clearInterval(timer); + if (!terminalNotified) { + terminalNotified = true; + if (nextTask.status === 'success') { + notify('success', `${activeTask.appName || 'Task'} completed`); + } else if (nextTask.status === 'failed') { + notify('failure', nextTask.errorMessage || `Task ${nextTask.taskId} failed`); + } else { + notify('warning', `Task ${nextTask.taskId} ${nextTask.status}`); + } + } } } @@ -315,7 +458,7 @@ function App() { disposed = true; window.clearInterval(timer); }; - }, [activeTask?.taskId, loadTaskSnapshot]); + }, [activeTask?.action, activeTask?.appName, activeTask?.status, activeTask?.taskId, loadTaskSnapshot, notify]); useEffect(() => { if (!toast) return undefined; @@ -340,9 +483,9 @@ function App() {
Runtime
${latestAgentPackage.version}` : ''}` : '127.0.0.1:5010'} + tone={agentNeedsUpdate ? 'warning' : (agentHealth ? 'success' : 'danger')} /> @@ -418,7 +561,7 @@ function App() { - + {!agentHealth && ( @@ -426,7 +569,21 @@ function App() {