Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions .github/workflows/test-sfw-mirror.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
name: 'test: sfw mirror'
run-name: 'test: sfw mirror'

# Sfw binary download origins can fail, ensure the action still works
# if only one of them is unavailable

on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:

permissions:
contents: read

jobs:
fault-download-origin:
name: 'Block a download origin (${{ matrix.os }}, ${{ matrix.blocked }})'
runs-on: ${{ matrix.os }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
os: [windows-2025, ubuntu-26.04]
# Each origin must carry the install alone. The github case only
# passes with the mirror fallback in the checked-out action.
blocked: [github, mirror]
steps:
- name: 'Bootstrap checkout'
shell: bash
env:
GITHUB_TOKEN: ${{ github.token }}
SERVER_URL: ${{ github.server_url }}
REPOSITORY: ${{ github.repository }}
TRIGGER_REF: ${{ github.sha }}
run: |
set -euo pipefail
git init -q
git config --local advice.detachedHead false
git remote add origin "${SERVER_URL}/${REPOSITORY}"
AUTH_B64="$(printf 'x-access-token:%s' "${GITHUB_TOKEN}" | base64 | tr -d '\n')"
export GIT_CONFIG_COUNT=1
export GIT_CONFIG_KEY_0="http.${SERVER_URL}/.extraheader"
export GIT_CONFIG_VALUE_0="AUTHORIZATION: basic ${AUTH_B64}"
git fetch --no-tags --prune --depth 1 origin "${TRIGGER_REF}"
git checkout -q --detach FETCH_HEAD
- name: 'Block the origin in the hosts file'
shell: bash
env:
BLOCKED: ${{ matrix.blocked }}
RUNNER_OS: ${{ runner.os }}
run: |
set -euo pipefail
if [ "$RUNNER_OS" = Windows ]; then HOSTS="$WINDIR/System32/drivers/etc/hosts"; else HOSTS=/etc/hosts; fi
if [ "$BLOCKED" = github ]; then
hosts="github.com objects.githubusercontent.com release-assets.githubusercontent.com"
else
hosts="install.socket.dev"
fi
# 127.0.0.1 refuses the connection at once. A black-hole address would
# make every attempt wait out a TCP connect timeout, which on Linux
# outlives the job.
for h in $hosts; do printf '127.0.0.1 %s\n' "$h" | sudo tee -a "$HOSTS" > /dev/null 2>&1 || printf '127.0.0.1 %s\n' "$h" >> "$HOSTS"; done
[ "$RUNNER_OS" = Windows ] && ipconfig //flushdns > /dev/null || true
- name: 'Install socket firewall via the remaining origin'
uses: ./
with:
mode: firewall
job-summary: errors
use-cache: 'false'
- name: 'Run the installed binary'
shell: bash
run: sfw --version
91 changes: 76 additions & 15 deletions dist/main.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

132 changes: 105 additions & 27 deletions src/tools/firewall.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,14 @@ export const FIREWALL_CHECKSUMS = {
*/
export const DOWNLOAD_RETRY_DELAYS_SECONDS = [30, 60]

/**
* Socket-owned mirror of the sfw-free release binaries, relayed by
* firewall-download-server in depscan. Free edition only: the enterprise
* repository is private and not mirrored.
*/
export const FIREWALL_FREE_MIRROR_BASE_URL =
'https://install.socket.dev/firewall/dl'

/**
* Name the firewall binary is cached and executed under.
*/
Expand Down Expand Up @@ -147,8 +155,16 @@ export async function downloadFirewall({ edition = 'free', ...inputs }) {
process.arch,
]

// construct the download url
const url = `https://github.com/SocketDev/${repo}/releases/download/${versionToDownload}/${nameDownload}`
// construct the download urls, tried in a random order per job: half the
// fleet's downloads keep the mirror's edge cache warm, which is what lets it
// keep serving during a GitHub release-asset incident.
const origins = firewallDownloadUrls(
edition,
repo,
versionToDownload,
nameDownload,
)
const urls = shuffledIndexes(origins.length).map(i => origins[i])

let pathCache

Expand All @@ -159,13 +175,13 @@ export async function downloadFirewall({ edition = 'free', ...inputs }) {

// no cache, download new
if (!pathCache) {
debug(`downloading Socket Firewall binary from: ${url}`)
debug(`downloading Socket Firewall binary from: ${urls.join(', ')}`)

let pathDownload

try {
// download it
pathDownload = await downloadToolWithRetry(url)
pathDownload = await downloadToolWithRetry(urls)
} catch (error) {
throw new Error(
`Failed to download Socket Firewall binary: ${errorMessage(error)}`,
Expand Down Expand Up @@ -228,41 +244,83 @@ export async function downloadFirewall({ edition = 'free', ...inputs }) {
}

/**
* `downloadTool` with attempts layered on top of its own. The last error is
* rethrown untouched so the caller still reports the real cause.
* `downloadTool` with attempts layered on top of its own, across equivalent
* origins. Every origin gets the whole delay table to itself: a round tries
* each origin still in play, and a round that leaves none of them succeeding
* sits out the next delay before going again. An origin that fails with an
* error a retry cannot change (a 404) drops out of later rounds, so a mirror
* that lacks the asset cannot use up the retries GitHub needs to ride out a
* 504. The error rethrown is the last transient one when there was one, since
* that is the outage worth reporting; otherwise the last error seen.
*
* @param {string} url Asset to download.
* @param {string[]} urls Equivalent origins for the same asset, in the order
* to try them.
*
* @returns {Promise<string>} Path the asset was downloaded to.
*/
export async function downloadToolWithRetry(url) {
export async function downloadToolWithRetry(urls) {
let alive = urls
let lastError
let lastRetryableError

for (let round = 0; ; round += 1) {
const stillAlive = []

for (const url of alive) {
try {
return await downloadTool(url)
} catch (error) {
lastError = error

if (isRetryableDownloadError(error)) {
lastRetryableError = error
stillAlive.push(url)
warning(
`Socket Firewall binary download from ${url} failed (attempt ${round + 1} of ${DOWNLOAD_RETRY_DELAYS_SECONDS.length + 1}): ${errorMessage(error)}.`,
)
} else {
warning(
`Socket Firewall binary download from ${url} failed: ${errorMessage(error)}. Not retrying this origin.`,
)
}
}
}

for (
let attempt = 0;
attempt <= DOWNLOAD_RETRY_DELAYS_SECONDS.length;
attempt += 1
) {
try {
return await downloadTool(url)
} catch (error) {
lastError = error
const seconds = DOWNLOAD_RETRY_DELAYS_SECONDS[round]

const seconds = DOWNLOAD_RETRY_DELAYS_SECONDS[attempt]
if (stillAlive.length === 0 || seconds === undefined) {
throw lastRetryableError ?? lastError
}

if (seconds === undefined || !isRetryableDownloadError(error)) {
break
}
warning(`Retrying ${stillAlive.join(', ')} in ${seconds}s.`)
await setTimeout(seconds * 1000)
alive = stillAlive
}
}

warning(
`Socket Firewall binary download failed (attempt ${attempt + 1} of ${DOWNLOAD_RETRY_DELAYS_SECONDS.length + 1}): ${errorMessage(error)}. Retrying in ${seconds}s.`,
)
/**
* Equivalent origins to download one release asset from. GitHub release
* assets come first; the free edition is also mirrored on Socket-owned
* infrastructure. The enterprise repository is private and not mirrored, so it
* stays GitHub-only. Callers decide the order to try them in.
*
* @param {string} edition Firewall edition being installed.
* @param {string} repo GitHub repository the release lives in.
* @param {string} version Release tag to download.
* @param {string} asset Release asset name.
*
* @returns {string[]} Download URLs, GitHub first.
*/
export function firewallDownloadUrls(edition, repo, version, asset) {
const urls = [
`https://github.com/SocketDev/${repo}/releases/download/${version}/${asset}`,
]

await setTimeout(seconds * 1000)
}
if (edition === 'free') {
urls.push(`${FIREWALL_FREE_MIRROR_BASE_URL}/${version}/${asset}`)
}

throw lastError
return urls
}

export function firewallReleaseVersion(requestedVersion) {
Expand Down Expand Up @@ -315,6 +373,26 @@ export function isRetryableDownloadError(error) {
return status >= 500 || status === 408 || status === 429
}

/**
* A random permutation of `0 .. length - 1` (Fisher-Yates), for callers that
* need to try equivalent options in an unbiased order.
*
* @param {number} length How many indexes to permute.
* @param {() => number} random Injectable for tests.
*
* @returns {number[]} Every index once, in random order.
*/
export function shuffledIndexes(length, random = Math.random) {
const order = Array.from({ length }, (_, i) => i)
for (let i = order.length - 1; i > 0; i--) {
const j = Math.floor(random() * (i + 1))
const swap = order[i]
order[i] = order[j]
order[j] = swap
}
return order
}

/**
* Compare a downloaded binary against the hash pinned for its release and
* throw when they differ. Both hashes are printed so an operator can tell a
Expand Down
Loading
Loading