Last active
February 22, 2025 07:33
-
-
Save Garciat/66dbdb1b7534c63e82560a7d0440bdb8 to your computer and use it in GitHub Desktop.
Manage your VS Code Remote Tunnel with Deno + CGI.
This file contains 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
#!/usr/bin/env -S /home/garciat/homebrew/bin/deno --allow-env --allow-run --allow-read --allow-write --allow-net | |
import "jsr:@std/dotenv/load"; | |
import { readAllSync } from "jsr:@std/io/read-all"; | |
import { writeAllSync } from "jsr:@std/io/write-all"; | |
import { deleteCookie, getCookies, setCookie } from "jsr:@std/http/cookie"; | |
import { Application, Context, Router } from "jsr:@oak/oak"; | |
import { render } from "npm:preact-render-to-string"; | |
import { LoginTicket, OAuth2Client } from "npm:google-auth-library"; | |
const css = String.raw; | |
function assertNotNull<T>(t: T): NonNullable<T> { | |
if (t === null || t === undefined) { | |
throw new Error("expected non-null"); | |
} | |
return t; | |
} | |
function decodeText(input: BufferSource): string { | |
return new TextDecoder().decode(input); | |
} | |
function newExceptionResponse(e: Error | HTTPError): Response { | |
const status = e instanceof HTTPError ? e.status : 500; | |
return newPlainTextResponse(`Uncaught Error:\n\n${e?.stack}`, status); | |
} | |
function newPlainTextResponse(text: string, status = 200): Response { | |
return new Response(text, { | |
status, | |
headers: { | |
"content-type": "text/plain", | |
}, | |
}); | |
} | |
function newHTMLResponse(contents: string): Response { | |
return new Response(`<!DOCTYPE html>\n${contents}`, { | |
headers: { | |
"content-type": "text/html", | |
}, | |
}); | |
} | |
function newFileResponse(mime: string, contents: string): Response { | |
return new Response(contents, { | |
headers: { | |
"content-type": mime, | |
}, | |
}); | |
} | |
function newRedirectResponse(uri: string): Response { | |
return new Response("", { | |
status: 302, | |
headers: { | |
"content-type": "text/html", | |
"location": uri, | |
}, | |
}); | |
} | |
function newLogoutResponse(redirect: string, cookies: string[]): Response { | |
const headers = new Headers({ | |
"content-type": "text/html", | |
"location": redirect, | |
}); | |
for (const cookie of cookies) { | |
deleteCookie(headers, cookie, { | |
"secure": true, | |
}); | |
} | |
return new Response("", { | |
status: 302, | |
headers, | |
}); | |
} | |
abstract class HTTPError extends Error { | |
abstract readonly status: number; | |
} | |
class HTTPBadRequestError extends HTTPError { | |
readonly status = 400; | |
} | |
class HTTPUnauthorized extends HTTPError { | |
readonly status = 401; | |
} | |
class CGI { | |
static parseRequest(env: Deno.Env): Request { | |
const method = assertNotNull(env.get("REQUEST_METHOD")); | |
const scheme = assertNotNull(env.get("REQUEST_SCHEME")); | |
const host = assertNotNull(env.get("HTTP_HOST")); | |
const uri = assertNotNull(env.get("REQUEST_URI")); | |
const url = `${scheme}://${host}${uri}`; | |
const headers = new Headers(); | |
for (const [name, value] of Object.entries(env.toObject())) { | |
if (name.startsWith("HTTP_")) { | |
const rest = name.slice("HTTP_".length); | |
const parts = rest.split("_"); | |
const headerName = parts.join("-").toLowerCase(); | |
headers.set(headerName, value); | |
} | |
} | |
const body = readAllSync(Deno.stdin); | |
return new Request(url.toString(), { | |
method, | |
headers, | |
body: method === "GET" || method === "HEAD" ? null : body, | |
}); | |
} | |
static async writeReponse(res: Response): Promise<void> { | |
const enc = new TextEncoder(); | |
Deno.stdout.writeSync( | |
enc.encode(`Status: ${res.status} ${res.statusText}\r\n`), | |
); | |
for (const [name, value] of res.headers.entries()) { | |
Deno.stdout.writeSync(enc.encode(`${name}: ${value}\r\n`)); | |
} | |
Deno.stdout.writeSync(enc.encode("\r\n")); | |
const bytes = await res.bytes(); | |
writeAllSync(Deno.stdout, bytes); | |
Deno.stdout.writeSync(enc.encode("\r\n")); | |
Deno.stdout.close(); | |
} | |
} | |
type BasicHandler = (req: Request) => Promise<Response>; | |
type RouteParams = Record<string, string | undefined>; | |
type RouteHandler = (req: Request, params: RouteParams) => Promise<Response>; | |
type RouteMethod = "GET" | "POST"; | |
type RoutePath = string; | |
type Routes = ReadonlyArray<Readonly<[RouteMethod, RoutePath, RouteHandler]>>; | |
function handleStatic(mime: string, contents: string): BasicHandler { | |
return () => Promise.resolve(newFileResponse(mime, contents)); | |
} | |
function googleAuthMiddleware( | |
options: { | |
baseURL: string; | |
tokenCookieName: string; | |
clientId: string; | |
emailAllowlist: string[]; | |
}, | |
) { | |
const handleBadAuth = () => { | |
return newLogoutResponse(options.baseURL, [options.tokenCookieName]); | |
}; | |
const showAuthPage = () => { | |
function main(callbackName: string) { | |
Object.defineProperty(globalThis, callbackName, { | |
value: async (token: { credential: string }) => { | |
const response = await fetch("", { | |
method: "POST", | |
body: token.credential, | |
}); | |
console.log(await response.text()); | |
if (response.ok) { | |
globalThis.location.reload(); | |
} else { | |
alert("Could not auth your user."); | |
} | |
}, | |
}); | |
} | |
const callbackName = "handleToken"; | |
const source = `${main}; main("${callbackName}");`; | |
const doc = ( | |
<html style="height:100%;display:flex;color-scheme:dark"> | |
<head> | |
<meta charset="UTF-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1" /> | |
</head> | |
<body style="flex-grow:1;display:flex;align-items:center;justify-content:center"> | |
<script src="https://accounts.google.com/gsi/client" async /> | |
<script dangerouslySetInnerHTML={{ __html: source }}></script> | |
<style | |
dangerouslySetInnerHTML={{ | |
__html: "iframe{color-scheme: normal;}", | |
}} | |
> | |
</style> | |
<div | |
id="g_id_onload" | |
data-client_id={options.clientId} | |
data-use_fedcm_for_prompt="true" | |
data-callback={callbackName} | |
> | |
</div> | |
<div | |
class="g_id_signin" | |
data-type="standard" | |
data-size="large" | |
data-theme="filled_blue" | |
data-text="signin" | |
data-shape="pill" | |
data-logo_alignment="left" | |
> | |
</div> | |
</body> | |
</html> | |
); | |
return newHTMLResponse(render(doc)); | |
}; | |
const handleAuthSave = async (req: Request) => { | |
const token = await req.text(); | |
if (!await verifyAuth(token)) { | |
return new Response("", { status: 401 }); | |
} | |
const headers = new Headers(); | |
setCookie(headers, { | |
name: options.tokenCookieName, | |
value: token, | |
secure: true, | |
}); | |
return new Response("", { | |
status: 201, | |
headers, | |
}); | |
}; | |
const verifyAuth = async (token: string) => { | |
const client = new OAuth2Client(); | |
let ticket: LoginTicket; | |
try { | |
ticket = await client.verifyIdToken({ | |
idToken: token, | |
audience: options.clientId, | |
}); | |
} catch { | |
return false; | |
} | |
const payload = ticket.getPayload(); | |
const email = payload?.email; | |
if (!email) { | |
return false; | |
} | |
if (!options.emailAllowlist.includes(email)) { | |
return false; | |
} | |
return true; | |
}; | |
const handler = async (req: Request) => { | |
const cookies = getCookies(req.headers); | |
const token = cookies[options.tokenCookieName]; | |
if (!token) { | |
switch (req.method) { | |
case "GET": | |
return showAuthPage(); | |
case "POST": | |
return handleAuthSave(req); | |
default: | |
throw new HTTPUnauthorized("no auth"); | |
} | |
} | |
if (await verifyAuth(token)) { | |
return null; // signal to use next middleware | |
} else { | |
return handleBadAuth(); | |
} | |
}; | |
// adapt to oak | |
return async (ctx: Context, next: () => Promise<unknown>) => { | |
const res = await handler(ctx.request.source!); | |
if (res) return ctx.response.with(res); | |
await next(); | |
}; | |
} | |
function oak(handler: BasicHandler): (ctx: Context) => Promise<void> { | |
return async (ctx) => ctx.response.with(await handler(ctx.request.source!)); | |
} | |
class App { | |
readonly handle: BasicHandler; | |
constructor( | |
private readonly options: { | |
scriptName: string; | |
tokenCookieName: string; | |
vscodeCommand: string; | |
vscodeOutputFile: string; | |
vscodeCwd: string; | |
googleClientId: string; | |
googleEmailAllowlist: string[]; | |
}, | |
) { | |
const router = new Router({ prefix: this.options.scriptName }); | |
router.get("/", oak(this.index)); | |
router.post("/logout", oak(this.logout)); | |
router.post("/start", oak(this.start)); | |
router.post("/kill", oak(this.kill("TERM"))); | |
router.post("/kill9", oak(this.kill("9"))); | |
router.get("/main.css", oak(handleStatic("text/css", this.mainCSS))); | |
const app = new Application(); | |
app.use(googleAuthMiddleware({ | |
baseURL: this.route("/"), | |
tokenCookieName: this.options.tokenCookieName, | |
clientId: this.options.googleClientId, | |
emailAllowlist: this.options.googleEmailAllowlist, | |
})); | |
app.use(router.routes()); | |
app.use(router.allowedMethods()); | |
this.handle = async (req) => (await app.handle(req))!; | |
} | |
route(path: string): string { | |
return `${this.options.scriptName}${path}`; | |
} | |
readonly index = async () => { | |
const cmdPid = await PosixTools.pgrepCommand( | |
this.options.vscodeCommand, | |
); | |
const allPids = cmdPid ? await PosixTools.pgrepWholeTree(cmdPid) : []; | |
const processes = (await Promise.all(allPids.map(PosixTools.psInfo))) | |
.flatMap((process) => process === null ? [] : [process]); | |
const output = decodeText( | |
await Deno.readFile(this.options.vscodeOutputFile), | |
).replaceAll(/(https:\/\/[^\(\)\s]+)/g, `<a href="$1">$1</a>`); | |
const doc = ( | |
<html> | |
<head> | |
<meta charset="UTF-8" /> | |
<title>VS Code Tunnel Manager</title> | |
<meta name="viewport" content="width=device-width, initial-scale=1" /> | |
<link rel="stylesheet" href={this.route("/main.css")} /> | |
</head> | |
<body> | |
<main> | |
<header class="hstack"> | |
<h1>VS Code Tunnel Manager</h1> | |
<div class="spacer" /> | |
<aside> | |
Status {cmdPid ? "🟢" : "🔴"} | |
</aside> | |
</header> | |
<section class="hstack gap-small"> | |
{!cmdPid && ( | |
<> | |
<form action={this.route("/start")} method="post"> | |
<button type="submit">Start</button> | |
</form> | |
</> | |
)} | |
{cmdPid && ( | |
<> | |
<form action={this.route("/kill")} method="post"> | |
<button type="submit" hidden>Kill -TERM</button> | |
</form> | |
<form action={this.route("/kill9")} method="post"> | |
<button type="submit">Kill -9</button> | |
</form> | |
</> | |
)} | |
<form action={this.route("/logout")} method="post"> | |
<button type="submit">Log out</button> | |
</form> | |
</section> | |
<section> | |
<h2>Processes</h2> | |
<pre> | |
{processes.map((process) => ( | |
`${process.pid}\t${process.command}\n` | |
))} | |
</pre> | |
</section> | |
<section> | |
<h2>Output</h2> | |
<pre dangerouslySetInnerHTML={{ __html: output }}></pre> | |
</section> | |
</main> | |
</body> | |
</html> | |
); | |
return newHTMLResponse(render(doc)); | |
}; | |
readonly logout = () => | |
Promise.resolve(newLogoutResponse( | |
this.route("/"), | |
[this.options.tokenCookieName], | |
)); | |
readonly start = async () => { | |
const cmdPid = await PosixTools.pgrepCommand(this.options.vscodeCommand); | |
if (cmdPid !== null) { | |
throw new HTTPBadRequestError("Program is already running"); | |
} | |
await Deno.truncate(this.options.vscodeOutputFile); | |
const command = new Deno.Command("/usr/bin/env", { | |
args: [ | |
"bash", | |
"-c", | |
`cd ${this.options.vscodeCwd}; ${this.options.vscodeCommand} 2>&1 1>${this.options.vscodeOutputFile} &`, | |
], | |
clearEnv: true, | |
stdin: "null", | |
stdout: "null", | |
stderr: "null", | |
}); | |
const child = command.spawn(); | |
child.unref(); | |
return newRedirectResponse(this.route("/")); | |
}; | |
readonly kill = (mode: "TERM" | "9") => async () => { | |
const cmdPid = await PosixTools.pgrepCommand(this.options.vscodeCommand); | |
if (cmdPid === null) { | |
throw new HTTPBadRequestError("Program is not running"); | |
} | |
const allPids = await PosixTools.pgrepWholeTree(cmdPid); | |
for (const pid of allPids.toReversed()) { | |
try { | |
await PosixTools.kill(pid, mode); | |
} catch { | |
continue; | |
} | |
} | |
await Deno.truncate(this.options.vscodeOutputFile); | |
return newRedirectResponse(this.route("/")); | |
}; | |
readonly mainCSS = css` | |
html { | |
color-scheme: dark; | |
height: 100%; | |
display: flex; | |
flex-direction: column; | |
} | |
body { | |
flex-grow: 1; | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
justify-content: flex-start; | |
margin: 0; | |
font-family: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji; | |
} | |
main { | |
width: min(48rem, 100vw - 2rem); | |
padding: 1rem; | |
display: flex; | |
flex-direction: column; | |
} | |
h1, h2, h3 { | |
font-weight: normal; | |
} | |
pre { | |
overflow: auto; | |
} | |
form { | |
display: contents; | |
} | |
.hstack { | |
display: flex; | |
flex-direction: row; | |
align-items: center; | |
} | |
.gap-small { | |
gap: 0.5rem; | |
} | |
.spacer { | |
flex-grow: 1; | |
} | |
`; | |
} | |
class PosixTools { | |
static async pgrepCommand(cmd: string): Promise<number | null> { | |
const command = new Deno.Command("pgrep", { | |
args: ["-f", cmd], | |
}); | |
const { code, stdout } = await command.output(); | |
if (code !== 0) { | |
return null; | |
} | |
const out = new TextDecoder().decode(stdout); | |
return parseInt(out.trim()); | |
} | |
static async pgrepChildren(pid: number): Promise<number[]> { | |
const command = new Deno.Command("pgrep", { | |
args: ["-P", String(pid)], | |
}); | |
const { code, stdout } = await command.output(); | |
if (code !== 0) { | |
return []; | |
} | |
const out = new TextDecoder().decode(stdout); | |
return out.trim().split("\n").map((line) => parseInt(line)); | |
} | |
static async pgrepWholeTree(pid: number): Promise<number[]> { | |
const pids = [pid]; | |
for (const child of await this.pgrepChildren(pid)) { | |
pids.push(...await this.pgrepWholeTree(child)); | |
} | |
return pids; | |
} | |
static async psInfo(pid: number) { | |
const command = new Deno.Command("ps", { | |
args: ["-o", "pid=,command=", "-p", String(pid)], | |
}); | |
const { code, stdout } = await command.output(); | |
if (code !== 0) { | |
return null; | |
} | |
{ | |
const out = new TextDecoder().decode(stdout); | |
const match = out.trim().match(/^([0-9]+) (.*)$/); | |
if (match === null) { | |
throw new Error(`bad format: "${out}"`); | |
} | |
return { | |
pid: parseInt(match[1]), | |
command: match[2], | |
}; | |
} | |
} | |
static async kill(pid: number, mode: "TERM" | "9"): Promise<void> { | |
const command = new Deno.Command("kill", { | |
args: [`-${mode}`, String(pid)], | |
}); | |
const { code } = await command.output(); | |
if (code !== 0) { | |
throw new Error("fail: kill"); | |
} | |
} | |
} | |
async function main() { | |
const app = new App({ | |
scriptName: assertNotNull(Deno.env.get("SCRIPT_NAME")), | |
tokenCookieName: "auth_token", | |
vscodeCommand: assertNotNull(Deno.env.get("VSCODE_COMMAND")), | |
vscodeOutputFile: assertNotNull(Deno.env.get("VSCODE_OUTPUT_FILE")), | |
vscodeCwd: assertNotNull(Deno.env.get("VSCODE_CWD")), | |
googleClientId: assertNotNull(Deno.env.get("GOOGLE_CLIENT_ID")), | |
googleEmailAllowlist: assertNotNull(Deno.env.get("AUTH_USER_EMAIL")).split( | |
",", | |
), | |
}); | |
const req = CGI.parseRequest(Deno.env); | |
const res = await app.handle(req); | |
await CGI.writeReponse(res); | |
} | |
try { | |
await main(); | |
} catch (e) { | |
await CGI.writeReponse(newExceptionResponse(e as Error)); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment