Skip to content

Commit b5cc177

Browse files
committed
feat(firewall): download from a random origin with cross-origin fallback
The free-edition binary is now also served from install.socket.dev/firewall/dl (Socket-owned, checksum-pinned in this action's source, so a second host cannot swap bytes). Each job picks one of the two origins at random, which keeps the mirror's edge cache warm on roughly half the fleet's downloads, and the retry schedule alternates origins. A non-retryable error like a 404 still gets one immediate try of the other origin, since a missing asset on one host says nothing about the other; that also makes this safe to ship before the mirror endpoint is live. Enterprise stays GitHub-only: its repository is private and not mirrored.
1 parent 401464a commit b5cc177

3 files changed

Lines changed: 220 additions & 38 deletions

File tree

‎dist/main.js‎

Lines changed: 56 additions & 15 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src/tools/firewall.js‎

Lines changed: 77 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,13 @@ export const FIREWALL_CHECKSUMS = {
8787
*/
8888
export const DOWNLOAD_RETRY_DELAYS_SECONDS = [30, 60]
8989

90+
/**
91+
* Socket-owned mirror of the sfw-free release binaries, relayed by
92+
* firewall-download-server in depscan. Free edition only: the enterprise
93+
* repository is private and not mirrored.
94+
*/
95+
export const FIREWALL_MIRROR_BASE_URL = 'https://install.socket.dev/firewall/dl'
96+
9097
/**
9198
* Name the firewall binary is cached and executed under.
9299
*/
@@ -147,8 +154,13 @@ export async function downloadFirewall({ edition = 'free', ...inputs }) {
147154
process.arch,
148155
]
149156

150-
// construct the download url
151-
const url = `https://github.com/SocketDev/${repo}/releases/download/${versionToDownload}/${nameDownload}`
157+
// construct the download urls
158+
const urls = firewallDownloadUrls(
159+
edition,
160+
repo,
161+
versionToDownload,
162+
nameDownload,
163+
)
152164

153165
let pathCache
154166

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

160172
// no cache, download new
161173
if (!pathCache) {
162-
debug(`downloading Socket Firewall binary from: ${url}`)
174+
debug(`downloading Socket Firewall binary from: ${urls.join(', ')}`)
163175

164176
let pathDownload
165177

166178
try {
167179
// download it
168-
pathDownload = await downloadToolWithRetry(url)
180+
pathDownload = await downloadToolWithRetry(urls)
169181
} catch (error) {
170182
throw new Error(
171183
`Failed to download Socket Firewall binary: ${errorMessage(error)}`,
@@ -228,43 +240,94 @@ export async function downloadFirewall({ edition = 'free', ...inputs }) {
228240
}
229241

230242
/**
231-
* `downloadTool` with attempts layered on top of its own. The last error is
232-
* rethrown untouched so the caller still reports the real cause.
243+
* `downloadTool` with attempts layered on top of its own, alternating between
244+
* equivalent origins. Retryable failures spend the delay table; an error that
245+
* a retry cannot change (a 404) still gets one immediate try per remaining
246+
* origin, because a missing asset on one host says nothing about the others.
247+
* The last error is rethrown untouched so the caller still reports the real
248+
* cause.
233249
*
234-
* @param {string} url Asset to download.
250+
* @param {string[]} urls Equivalent origins for the same asset, in the order
251+
* to try them.
235252
*
236253
* @returns {Promise<string>} Path the asset was downloaded to.
237254
*/
238-
export async function downloadToolWithRetry(url) {
255+
export async function downloadToolWithRetry(urls) {
239256
let lastError
240257

241258
for (
242259
let attempt = 0;
243260
attempt <= DOWNLOAD_RETRY_DELAYS_SECONDS.length;
244261
attempt += 1
245262
) {
263+
const url = urls[attempt % urls.length]
264+
246265
try {
247266
return await downloadTool(url)
248267
} catch (error) {
249268
lastError = error
250269

251270
const seconds = DOWNLOAD_RETRY_DELAYS_SECONDS[attempt]
252271

253-
if (seconds === undefined || !isRetryableDownloadError(error)) {
272+
if (seconds === undefined) {
254273
break
255274
}
256275

257-
warning(
258-
`Socket Firewall binary download failed (attempt ${attempt + 1} of ${DOWNLOAD_RETRY_DELAYS_SECONDS.length + 1}): ${errorMessage(error)}. Retrying in ${seconds}s.`,
259-
)
260-
261-
await setTimeout(seconds * 1000)
276+
if (isRetryableDownloadError(error)) {
277+
warning(
278+
`Socket Firewall binary download from ${url} failed (attempt ${attempt + 1} of ${DOWNLOAD_RETRY_DELAYS_SECONDS.length + 1}): ${errorMessage(error)}. Retrying in ${seconds}s.`,
279+
)
280+
await setTimeout(seconds * 1000)
281+
} else if (attempt < urls.length - 1) {
282+
warning(
283+
`Socket Firewall binary download from ${url} failed: ${errorMessage(error)}. Trying ${urls[(attempt + 1) % urls.length]}.`,
284+
)
285+
} else {
286+
break
287+
}
262288
}
263289
}
264290

265291
throw lastError
266292
}
267293

294+
/**
295+
* Origins to download one release asset from, in the order to try them. The
296+
* free edition is also mirrored on Socket-owned infrastructure, and one of the
297+
* two origins is picked at random per job: half the fleet's downloads keep the
298+
* mirror's edge cache warm, which is what lets it keep serving during a GitHub
299+
* release-asset incident. The enterprise repository is private and not
300+
* mirrored, so it stays GitHub-only.
301+
*
302+
* @param {string} edition Firewall edition being installed.
303+
* @param {string} repo GitHub repository the release lives in.
304+
* @param {string} version Release tag to download.
305+
* @param {string} asset Release asset name.
306+
* @param {() => number} random Injectable for tests.
307+
*
308+
* @returns {string[]} Download URLs, primary origin first.
309+
*/
310+
export function firewallDownloadUrls(
311+
edition,
312+
repo,
313+
version,
314+
asset,
315+
random = Math.random,
316+
) {
317+
const urls = [
318+
`https://github.com/SocketDev/${repo}/releases/download/${version}/${asset}`,
319+
]
320+
321+
if (edition === 'free') {
322+
urls.push(`${FIREWALL_MIRROR_BASE_URL}/${version}/${asset}`)
323+
if (random() < 0.5) {
324+
urls.reverse()
325+
}
326+
}
327+
328+
return urls
329+
}
330+
268331
export function firewallReleaseVersion(requestedVersion) {
269332
let versionToDownload = FIREWALL_VERSION
270333

‎test/unit/tools/firewall.test.mts‎

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
downloadToolWithRetry,
1414
FIREWALL_DISTRIBUTIONS,
1515
FIREWALL_EXEC_NAME,
16+
firewallDownloadUrls,
1617
isRetryableDownloadError,
1718
} from '../../../src/tools/firewall.js'
1819

@@ -146,35 +147,112 @@ describe('isRetryableDownloadError', () => {
146147
})
147148

148149
describe('downloadToolWithRetry', () => {
150+
const GITHUB = 'https://github.test/sfw'
151+
const MIRROR = 'https://mirror.test/sfw'
152+
149153
it('returns the path once an attempt succeeds', async () => {
150154
mockDownloadTool
151155
.mockRejectedValueOnce(httpError(504))
152156
.mockResolvedValueOnce('/tmp/sfw')
153157

154-
await expect(
155-
downloadToolWithRetry('https://example.test/sfw'),
156-
).resolves.toBe('/tmp/sfw')
158+
await expect(downloadToolWithRetry([GITHUB])).resolves.toBe('/tmp/sfw')
157159
expect(mockDownloadTool).toHaveBeenCalledTimes(2)
158160
expect(sleepDelays).toEqual([30_000])
159161
})
160162

161163
it('rethrows the last error after exhausting its attempts', async () => {
162164
mockDownloadTool.mockRejectedValue(httpError(504))
163165

164-
await expect(
165-
downloadToolWithRetry('https://example.test/sfw'),
166-
).rejects.toThrow('Unexpected HTTP response: 504')
166+
await expect(downloadToolWithRetry([GITHUB])).rejects.toThrow(
167+
'Unexpected HTTP response: 504',
168+
)
167169
expect(mockDownloadTool).toHaveBeenCalledTimes(3)
168170
expect(sleepDelays).toEqual([30_000, 60_000])
169171
})
170172

171173
it('does not spend attempts on a missing asset', async () => {
172174
mockDownloadTool.mockRejectedValue(httpError(404))
173175

174-
await expect(
175-
downloadToolWithRetry('https://example.test/sfw'),
176-
).rejects.toThrow('Unexpected HTTP response: 404')
176+
await expect(downloadToolWithRetry([GITHUB])).rejects.toThrow(
177+
'Unexpected HTTP response: 404',
178+
)
177179
expect(mockDownloadTool).toHaveBeenCalledTimes(1)
178180
expect(sleepDelays).toEqual([])
179181
})
182+
183+
it('alternates origins across the retry schedule', async () => {
184+
mockDownloadTool.mockRejectedValue(httpError(504))
185+
186+
await expect(downloadToolWithRetry([MIRROR, GITHUB])).rejects.toThrow(
187+
'Unexpected HTTP response: 504',
188+
)
189+
expect(mockDownloadTool.mock.calls).toEqual([[MIRROR], [GITHUB], [MIRROR]])
190+
expect(sleepDelays).toEqual([30_000, 60_000])
191+
})
192+
193+
it('tries the other origin immediately when a retry cannot help', async () => {
194+
// A 404 from the mirror says nothing about GitHub: no backoff, one
195+
// immediate try of the other origin.
196+
mockDownloadTool
197+
.mockRejectedValueOnce(httpError(404))
198+
.mockResolvedValueOnce('/tmp/sfw')
199+
200+
await expect(downloadToolWithRetry([MIRROR, GITHUB])).resolves.toBe(
201+
'/tmp/sfw',
202+
)
203+
expect(mockDownloadTool.mock.calls).toEqual([[MIRROR], [GITHUB]])
204+
expect(sleepDelays).toEqual([])
205+
})
206+
207+
it('gives up once every origin reported a missing asset', async () => {
208+
mockDownloadTool.mockRejectedValue(httpError(404))
209+
210+
await expect(downloadToolWithRetry([MIRROR, GITHUB])).rejects.toThrow(
211+
'Unexpected HTTP response: 404',
212+
)
213+
expect(mockDownloadTool).toHaveBeenCalledTimes(2)
214+
expect(sleepDelays).toEqual([])
215+
})
216+
})
217+
218+
describe('firewallDownloadUrls', () => {
219+
it('gives the free edition both origins, ordered by the coin flip', () => {
220+
const github =
221+
'https://github.com/SocketDev/sfw-free/releases/download/v1.15.2/sfw-free-linux-x86_64'
222+
const mirror =
223+
'https://install.socket.dev/firewall/dl/v1.15.2/sfw-free-linux-x86_64'
224+
225+
expect(
226+
firewallDownloadUrls(
227+
'free',
228+
'sfw-free',
229+
'v1.15.2',
230+
'sfw-free-linux-x86_64',
231+
() => 0.9,
232+
),
233+
).toEqual([github, mirror])
234+
expect(
235+
firewallDownloadUrls(
236+
'free',
237+
'sfw-free',
238+
'v1.15.2',
239+
'sfw-free-linux-x86_64',
240+
() => 0.1,
241+
),
242+
).toEqual([mirror, github])
243+
})
244+
245+
it('keeps the enterprise edition GitHub-only', () => {
246+
expect(
247+
firewallDownloadUrls(
248+
'enterprise',
249+
'firewall-release',
250+
'v1.15.2',
251+
'sfw-windows-x86_64.exe',
252+
() => 0.1,
253+
),
254+
).toEqual([
255+
'https://github.com/SocketDev/firewall-release/releases/download/v1.15.2/sfw-windows-x86_64.exe',
256+
])
257+
})
180258
})

0 commit comments

Comments
 (0)