Repository navigation
feat(azure): keep az's network traffic on the emulator with an egress guard #94
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
30e85ae
feat(azure): keep az's network traffic on the emulator with an egress…
DrisDary 258cdc6
docs(azure): explain az rest and simplify the egress guard intro
DrisDary 6eec6ee
fix(azure): address the review of the egress guard
DrisDary 744bbb7
refactor(azure): simplify the egress guard and its refusal note
DrisDary 90aa86a
fix(azure): hint for URLs az downloads, note wording, rest example on…
DrisDary File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,211 @@ | ||
| import http from "http"; | ||
| import net, { type AddressInfo } from "net"; | ||
| import { startEgressGuard, type EgressGuard } from "./egress-guard"; | ||
|
|
||
| // Local servers stand in for the emulator: an echo server that keeps its sockets, one that | ||
| // closes at once, and one that counts the connections it gets. | ||
| let echo: net.Server; | ||
| const echoSockets: net.Socket[] = []; | ||
| let closer: net.Server; | ||
| let counter: net.Server; | ||
| let echoPort: number; | ||
| let closerPort: number; | ||
| let counterPort: number; | ||
| let connections = 0; | ||
| let guard: EgressGuard; | ||
| const dialled: Array<[number, string]> = []; | ||
|
|
||
| const listen = (server: net.Server) => | ||
| new Promise<number>((resolve) => | ||
| server.listen(0, "127.0.0.1", () => resolve((server.address() as AddressInfo).port)) | ||
| ); | ||
| const guardPort = () => Number(new URL(guard.env.HTTPS_PROXY).port); | ||
|
|
||
| /** Sends CONNECT through the guard; resolves with its status line and the open socket. */ | ||
| function connectThrough(authority: string): Promise<{ line: string; socket: net.Socket }> { | ||
| return new Promise((resolve, reject) => { | ||
| const socket = net.connect(guardPort(), "127.0.0.1"); | ||
| socket.once("error", reject); | ||
| let head = ""; | ||
| const onData = (chunk: Buffer) => { | ||
| head += chunk.toString("latin1"); | ||
| if (!head.includes("\r\n\r\n")) return; | ||
| socket.off("data", onData); | ||
| resolve({ line: head.split("\r\n")[0], socket }); | ||
| }; | ||
| socket.on("data", onData); | ||
| socket.write(`CONNECT ${authority} HTTP/1.1\r\nHost: ${authority}\r\n\r\n`); | ||
| }); | ||
| } | ||
|
|
||
| /** Sends a plain-HTTP proxy request (absolute URI) through the guard. */ | ||
| function getThrough(url: string): Promise<{ status: number; reason: string }> { | ||
| return new Promise((resolve, reject) => { | ||
| const req = http.request({ host: "127.0.0.1", port: guardPort(), path: url }, (res) => { | ||
| res.resume(); | ||
| res.on("end", () => | ||
| resolve({ status: res.statusCode ?? 0, reason: res.statusMessage ?? "" }) | ||
| ); | ||
| }); | ||
| req.on("error", reject); | ||
| req.end(); | ||
| }); | ||
| } | ||
|
|
||
| /** The guard still answers: a tunnel to the echo server opens. */ | ||
| async function stillServes() { | ||
| const { line, socket } = await connectThrough(`localhost:${echoPort}`); | ||
| socket.destroy(); | ||
| return line; | ||
| } | ||
|
|
||
| beforeAll(async () => { | ||
| echo = net.createServer((socket) => { | ||
| socket.on("error", () => undefined); | ||
| echoSockets.push(socket); | ||
| socket.pipe(socket); | ||
| }); | ||
| echoPort = await listen(echo); | ||
| // Resets once the tunnel carries data, so the reset always comes after the guard's 200. | ||
| closer = net.createServer((socket) => socket.once("data", () => socket.resetAndDestroy())); | ||
| closerPort = await listen(closer); | ||
| counter = net.createServer((socket) => { | ||
| connections += 1; | ||
| socket.destroy(); | ||
| }); | ||
| counterPort = await listen(counter); | ||
| }); | ||
| afterAll(() => { | ||
| echo.close(); | ||
| closer.close(); | ||
| counter.close(); | ||
| }); | ||
| beforeEach(async () => { | ||
| dialled.length = 0; | ||
| connections = 0; | ||
| guard = await startEgressGuard({ | ||
| connect: (port, host) => { | ||
| dialled.push([port, host]); | ||
| // 443 is the emulator's https port in the wild; the echo server stands in for it. | ||
| return net.connect({ port: port === 443 ? echoPort : port, host, allowHalfOpen: true }); | ||
| }, | ||
| }); | ||
| }); | ||
| afterEach(() => guard.close()); | ||
|
|
||
| test("points az's HTTPS_PROXY and HTTP_PROXY at itself", () => { | ||
| expect(guard.env.HTTPS_PROXY).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); | ||
| expect(guard.env.HTTP_PROXY).toBe(guard.env.HTTPS_PROXY); | ||
| }); | ||
|
|
||
| test("tunnels to the emulator's names on loopback, without DNS", async () => { | ||
| for (const host of ["azure.localhost.localstack.cloud", "LOCALHOST", "127.1"]) { | ||
| const { line, socket } = await connectThrough(`${host}:${echoPort}`); | ||
| expect(line).toBe("HTTP/1.1 200 Connection Established"); | ||
| const echoed = new Promise<string>((resolve) => socket.once("data", (d) => resolve(String(d)))); | ||
| socket.write("ping"); | ||
| expect(await echoed).toBe("ping"); | ||
| socket.destroy(); | ||
| } | ||
| // Nothing listens on ::1, so the dial fails and the client gets a 502. | ||
| const ipv6 = await connectThrough(`[::1]:${echoPort}`); | ||
| expect(ipv6.line).toBe("HTTP/1.1 502 Bad Gateway"); | ||
| ipv6.socket.destroy(); | ||
| expect(dialled).toEqual([ | ||
| [echoPort, "127.0.0.1"], | ||
| [echoPort, "127.0.0.1"], | ||
| [echoPort, "127.0.0.1"], | ||
| [echoPort, "::1"], | ||
| ]); | ||
| expect(guard.refused()).toEqual([]); | ||
| }); | ||
|
|
||
| test("URL drops the default port; a tunnel to :443 still dials 443", async () => { | ||
| const { line, socket } = await connectThrough("azure.localhost.localstack.cloud:443"); | ||
| expect(line).toBe("HTTP/1.1 200 Connection Established"); | ||
| socket.destroy(); | ||
| expect(dialled).toEqual([[443, "127.0.0.1"]]); | ||
| }); | ||
|
|
||
| test("refuses every other host, naming it in the 403, and never dials it", async () => { | ||
| const lines: string[] = []; | ||
| for (const authority of [ | ||
| "storage.evil.example:443", | ||
| `10.0.0.5:${echoPort}`, | ||
| "localhost.localstack.cloud.evil.example:443", | ||
| "[2001:db8::1]:443", | ||
| ]) { | ||
| const { line, socket } = await connectThrough(authority); | ||
| lines.push(line); | ||
| socket.destroy(); | ||
| } | ||
| // Python puts the reason phrase into its error, so az's own stderr names the host too. | ||
| expect(lines[0]).toBe("HTTP/1.1 403 blocked by the egress guard: storage.evil.example"); | ||
| expect(lines.every((line) => line.startsWith("HTTP/1.1 403 "))).toBe(true); | ||
| expect(guard.refused()).toEqual([ | ||
| "storage.evil.example", | ||
| "10.0.0.5", | ||
| "localhost.localstack.cloud.evil.example", | ||
| "[2001:db8::1]", | ||
| ]); | ||
| expect(dialled).toEqual([]); | ||
| }); | ||
|
|
||
| test("a target that is not host:port is refused, and nothing is dialled", async () => { | ||
| for (const authority of [ | ||
| `localhost:+${echoPort}`, | ||
| `localhost:${echoPort}.0`, | ||
| `localhost:${echoPort}e0`, | ||
| `user@localhost:${echoPort}`, | ||
| `localhost:${echoPort}/path`, | ||
| "no-port-here", | ||
| ]) { | ||
| const { line, socket } = await connectThrough(authority); | ||
| expect([authority, line]).toEqual([authority, "HTTP/1.1 400 Bad Request"]); | ||
| socket.destroy(); | ||
| } | ||
| expect(dialled).toEqual([]); | ||
| }); | ||
|
|
||
| test("plain HTTP is never relayed, not even to the emulator: 403 naming the target", async () => { | ||
| expect(await getThrough(`http://localhost:${counterPort}/`)).toEqual({ | ||
| status: 403, | ||
| reason: `blocked by the egress guard: http://localhost:${counterPort}`, | ||
| }); | ||
| expect((await getThrough("http://evil.example/upload")).status).toBe(403); | ||
| expect((await getThrough("/not-absolute")).status).toBe(400); | ||
| expect(guard.refused()).toEqual([`http://localhost:${counterPort}`, "http://evil.example"]); | ||
| expect(connections).toBe(0); | ||
| expect(dialled).toEqual([]); | ||
| }); | ||
|
|
||
| test("a client that resets mid-tunnel takes the upstream down, and the guard keeps serving", async () => { | ||
| const { line, socket } = await connectThrough(`localhost:${echoPort}`); | ||
| expect(line).toBe("HTTP/1.1 200 Connection Established"); | ||
| // An echo makes sure the upstream side exists before the reset. | ||
| const echoed = new Promise((resolve) => socket.once("data", resolve)); | ||
| socket.write("ping"); | ||
| await echoed; | ||
| const upstream = echoSockets[echoSockets.length - 1]; | ||
| const upstreamClosed = new Promise((resolve) => upstream.once("close", resolve)); | ||
| socket.resetAndDestroy(); | ||
| await upstreamClosed; | ||
| expect(await stillServes()).toBe("HTTP/1.1 200 Connection Established"); | ||
| }); | ||
|
|
||
| test("an upstream that resets first ends the client's side of the tunnel", async () => { | ||
| const { line, socket } = await connectThrough(`localhost:${closerPort}`); | ||
| expect(line).toBe("HTTP/1.1 200 Connection Established"); | ||
| const ended = new Promise((resolve) => socket.once("end", resolve)); | ||
| socket.write("ping"); | ||
| await ended; | ||
| socket.destroy(); | ||
| expect(await stillServes()).toBe("HTTP/1.1 200 Connection Established"); | ||
| }); | ||
|
|
||
| test("close() ends an open tunnel", async () => { | ||
| const { socket } = await connectThrough(`localhost:${echoPort}`); | ||
| const closed = new Promise((resolve) => socket.once("close", resolve)); | ||
| await guard.close(); | ||
| await closed; | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.