diff --git a/Dockerfile b/Dockerfile index e99e1f7..9c28405 100644 --- a/Dockerfile +++ b/Dockerfile @@ -61,22 +61,23 @@ RUN DONUT_TAG=$(curl -sSf "https://api.github.com/repos/TheWover/donut/releases/ echo "WARNING: Donut pre-fetch failed — will fall back to system PATH or download on first use"; \ fi -# Pre-fetch the latest SGN (Shikata Ga Nai) polymorphic encoder. -# Used post-Donut to encode raw shellcode for AV/EDR evasion. The runtime -# sgn-manager will re-check GitHub daily and update automatically; this -# step just ensures a working binary is available offline / on first use. +# Pre-fetch the latest Rust SGN (Shikata Ga Nai) binary. The Rust releases use +# target-triple tarballs instead of the legacy sgn_linux_amd64_*.zip naming. +# The runtime sgn-manager re-checks GitHub daily; this provides an offline +# binary for supported Docker architectures on first start. RUN SGN_ASSET=$(curl -sSf "https://api.github.com/repos/EgeBalci/sgn/releases/latest" \ - | grep -oE '"browser_download_url":[[:space:]]*"[^"]*sgn_linux_amd64[^"]*\.zip"' \ + | grep -oE '"browser_download_url":[[:space:]]*"[^"]*sgn-x86_64-unknown-linux-musl\.tar\.gz"' \ | head -1 | cut -d'"' -f4) \ - && if [ -n "${SGN_ASSET}" ] && curl -sSfL "${SGN_ASSET}" -o /tmp/sgn.zip \ - && unzip -j -o /tmp/sgn.zip -d /usr/local/bin >/dev/null 2>&1 \ + && if [ "${TARGETARCH:-amd64}" = "amd64" ] \ + && [ -n "${SGN_ASSET}" ] \ + && curl -sSfL "${SGN_ASSET}" \ + | tar -xzf - -C /usr/local/bin sgn \ && [ -f /usr/local/bin/sgn ]; then \ chmod +x /usr/local/bin/sgn; \ echo "SGN pre-installed from ${SGN_ASSET}"; \ else \ - echo "WARNING: SGN pre-fetch failed — will fall back to system PATH or download on first use"; \ - fi \ - && rm -f /tmp/sgn.zip + echo "WARNING: SGN Rust binary is unavailable for ${TARGETARCH:-unknown} — will fall back to system PATH or runtime download"; \ + fi # Full bun install (includes devDeps needed for tailwind / vendor / minify steps) COPY Overlord-Server/package.json Overlord-Server/bun.lock* ./ diff --git a/Overlord-Server/src/server/sgn-manager.test.ts b/Overlord-Server/src/server/sgn-manager.test.ts new file mode 100644 index 0000000..ced50b9 --- /dev/null +++ b/Overlord-Server/src/server/sgn-manager.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test"; +import { selectSgnReleaseAsset } from "./sgn-manager"; + +const rustAssets = [ + { + name: "sgn-aarch64-apple-darwin.tar.gz", + browser_download_url: "https://example.test/sgn-aarch64-apple-darwin.tar.gz", + }, + { + name: "sgn-x86_64-pc-windows-msvc.zip", + browser_download_url: "https://example.test/sgn-x86_64-pc-windows-msvc.zip", + }, + { + name: "sgn-x86_64-unknown-linux-musl.tar.gz", + browser_download_url: "https://example.test/sgn-x86_64-unknown-linux-musl.tar.gz", + }, + { + name: "sgn-x86_64-unknown-linux-musl.tar.gz.zip", + browser_download_url: "https://example.test/sgn-x86_64-unknown-linux-musl.tar.gz.zip", + }, +]; + +describe("SGN Rust release selection", () => { + test("selects the native target-triple archive", () => { + expect(selectSgnReleaseAsset(rustAssets, "linux", "x64")).toMatchObject({ + assetName: "sgn-x86_64-unknown-linux-musl.tar.gz", + archiveFormat: "tar.gz", + }); + expect(selectSgnReleaseAsset(rustAssets, "win32", "x64")).toMatchObject({ + assetName: "sgn-x86_64-pc-windows-msvc.zip", + archiveFormat: "zip", + }); + expect(selectSgnReleaseAsset(rustAssets, "darwin", "arm64")).toMatchObject({ + assetName: "sgn-aarch64-apple-darwin.tar.gz", + archiveFormat: "tar.gz", + }); + }); + + test("does not substitute an incompatible architecture", () => { + expect(selectSgnReleaseAsset(rustAssets, "linux", "arm64")).toBeNull(); + expect(selectSgnReleaseAsset(rustAssets, "darwin", "x64")).toBeNull(); + }); +}); diff --git a/Overlord-Server/src/server/sgn-manager.ts b/Overlord-Server/src/server/sgn-manager.ts index 42caf11..2733006 100644 --- a/Overlord-Server/src/server/sgn-manager.ts +++ b/Overlord-Server/src/server/sgn-manager.ts @@ -1,6 +1,7 @@ import fs from "fs"; import path from "path"; import { $ } from "bun"; +import AdmZip from "adm-zip"; import { ensureDataDir } from "../paths"; import { logger } from "../logger"; @@ -42,7 +43,54 @@ async function findSystemSgn(): Promise { return null; } -type AssetInfo = { tag: string; downloadUrl: string; assetName: string }; +type SgnArchiveFormat = "zip" | "tar.gz"; +type ReleaseAsset = { name: string; browser_download_url: string }; +type AssetInfo = { + tag: string; + downloadUrl: string; + assetName: string; + archiveFormat: SgnArchiveFormat; +}; + +export function selectSgnReleaseAsset( + assets: ReleaseAsset[], + platform = process.platform, + arch = process.arch, +): Omit | null { + const target = platform === "win32" && arch === "x64" + ? { triple: "x86_64-pc-windows-msvc", format: "zip" as const } + : platform === "linux" && arch === "x64" + ? { triple: "x86_64-unknown-linux-musl", format: "tar.gz" as const } + : platform === "darwin" && arch === "arm64" + ? { triple: "aarch64-apple-darwin", format: "tar.gz" as const } + : null; + + if (target) { + const expectedName = `sgn-${target.triple}.${target.format}`.toLowerCase(); + const exact = assets.find((asset) => asset.name.toLowerCase() === expectedName); + if (exact) { + return { + downloadUrl: exact.browser_download_url, + assetName: exact.name, + archiveFormat: target.format, + }; + } + } + + const osTag = platform === "win32" ? "windows" : platform === "darwin" ? "macos" : "linux"; + const archTag = arch === "x64" ? "amd64" : arch === "ia32" ? "386" : arch; + const legacy = assets.find((asset) => { + const name = asset.name.toLowerCase(); + return name.startsWith(`sgn_${osTag}_${archTag}`) && name.endsWith(".zip"); + }); + return legacy + ? { + downloadUrl: legacy.browser_download_url, + assetName: legacy.name, + archiveFormat: "zip", + } + : null; +} async function fetchLatest(): Promise { try { @@ -53,53 +101,48 @@ async function fetchLatest(): Promise { if (!res.ok) return null; const data = await res.json() as any; const tag: string = data.tag_name; - const assets: { name: string; browser_download_url: string }[] = data.assets ?? []; - - // SGN release is named sgn___.zip - const osTag = process.platform === "win32" ? "windows" : process.platform === "darwin" ? "macos" : "linux"; - const archTag = process.arch === "x64" ? "amd64" : process.arch === "ia32" ? "386" : process.arch; - - const exact = assets.find(a => { - const n = a.name.toLowerCase(); - return n.startsWith(`sgn_${osTag}_${archTag}`) && n.endsWith(".zip"); - }); - if (exact) return { tag, downloadUrl: exact.browser_download_url, assetName: exact.name }; - - const looseOs = assets.find(a => a.name.toLowerCase().includes(`_${osTag}_`) && a.name.endsWith(".zip")); - if (looseOs) return { tag, downloadUrl: looseOs.browser_download_url, assetName: looseOs.name }; - - return null; + const selected = selectSgnReleaseAsset(data.assets ?? []); + return selected ? { tag, ...selected } : null; } catch { return null; } } -async function downloadAndExtract(url: string, dest: string): Promise { +async function downloadAndExtract( + url: string, + dest: string, + archiveFormat: SgnArchiveFormat, +): Promise { try { fs.mkdirSync(path.dirname(dest), { recursive: true }); const res = await fetch(url); if (!res.ok) return false; const buf = await res.arrayBuffer(); - const tmpZip = dest + ".tmp.zip"; - fs.writeFileSync(tmpZip, Buffer.from(buf)); + const tmpArchive = `${dest}.tmp.${archiveFormat === "zip" ? "zip" : "tar.gz"}`; + const tmpBinary = `${dest}.tmp.bin`; + fs.writeFileSync(tmpArchive, Buffer.from(buf)); try { - const destDir = path.dirname(dest); const binName = process.platform === "win32" ? "sgn.exe" : "sgn"; - const r = await $`unzip -o -j ${tmpZip} -d ${destDir}`.nothrow().quiet(); - if (r.exitCode !== 0) return false; - const extracted = path.join(destDir, binName); - if (!fs.existsSync(extracted)) { - const entries = fs.readdirSync(destDir); - const found = entries.find(e => e.toLowerCase().startsWith("sgn") && !e.endsWith(".version") && !e.endsWith(".tmp.zip")); - if (!found) return false; - fs.renameSync(path.join(destDir, found), dest); - } else if (extracted !== dest) { - fs.renameSync(extracted, dest); + if (archiveFormat === "zip") { + const zip = new AdmZip(tmpArchive); + const entry = zip.getEntries().find((candidate) => + !candidate.isDirectory && + path.basename(candidate.entryName).toLowerCase() === binName.toLowerCase() + ); + if (!entry) return false; + fs.writeFileSync(tmpBinary, entry.getData()); + } else { + const result = await $`tar -xOzf ${tmpArchive} sgn`.nothrow().quiet(); + if (result.exitCode !== 0 || result.stdout.length === 0) return false; + fs.writeFileSync(tmpBinary, result.stdout); } - try { fs.chmodSync(dest, 0o755); } catch {} + + try { fs.chmodSync(tmpBinary, 0o755); } catch {} + fs.renameSync(tmpBinary, dest); return true; } finally { - try { fs.unlinkSync(tmpZip); } catch {} + try { fs.unlinkSync(tmpArchive); } catch {} + try { fs.unlinkSync(tmpBinary); } catch {} } } catch { return false; @@ -149,7 +192,7 @@ export async function ensureSgn( } log(`SGN: downloading ${latest.assetName}…`); - const ok = await downloadAndExtract(latest.downloadUrl, bin); + const ok = await downloadAndExtract(latest.downloadUrl, bin, latest.archiveFormat); if (!ok) { if (binExists) { log(`SGN: download failed — using cached ${cached?.tag ?? "unknown"}`, "warn");