diff --git a/agent/index.js b/agent/index.js new file mode 100644 index 0000000..2642712 --- /dev/null +++ b/agent/index.js @@ -0,0 +1,184 @@ +import { nanoid } from "nanoid"; +// import { WebSocketServer } from "ws"; +import { execFile as _execFile, spawn } from "node:child_process"; +import { open, readFile, rm } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +const execFile = promisify(_execFile); +// const spawn = promisify(_spawn); + +const secret = await parseSecret(); + +// const wsServer = new WebSocketServer({ noServer: true }); +const server = createServer(async (req, res) => { + try { + await handler(req, res); + } catch (error) { + console.error("Error manejando", req.url, error); + res.destroy(); + } +}); + +/** @type {Map void)[], prev: Buffer, statusCode: null | number}>} */ +let runs = new Map(); + +// server.on("upgrade", (req, socket, head) => { +// const url = getUrl(req) +// let matches; +// if ((matches = url.pathname.match(runWebSocketRegexp))) { +// const runId = matches[1]; +// if (!listeners.has(runId)) { +// return +// } +// wsServer.handleUpgrade(req, socket,head,(client,request)=>{ + +// }) +// } +// }); + +/** + * @param {string} runId + * @param {Buffer} chunk + */ +function writeToRun(runId, chunk) { + const run = runs.get(runId); + if (!run) throw new Error(`runId ${runId} doesn't exist`); + run.prev = Buffer.concat([run.prev, chunk]); + for (const listener of run.listeners) { + listener(chunk); + } +} + +/** + * @param {string} runId + */ +function closeRun(runId) { + const run = runs.get(runId); + if (!run) throw new Error(`runId ${runId} doesn't exist`); + for (const listener of run.listeners) { + listener(null); + } +} + +const runWebSocketRegexp = /^\/run\/(.+)$/; + +/** + * @param {import("http").IncomingMessage} req + * @param {import("http").ServerResponse & { req: import("http").IncomingMessage; }} res + */ +async function handler(req, res) { + // prettier-ignore + const url = getUrl(req); + auth(secret, req, res); + let matches; + if (url.pathname === "/hello") { + if (req.method === "GET") { + return res.writeHead(200).end("Ey!"); + } else return res.writeHead(405).end("method not allowed"); + } else if (url.pathname === "/run") { + if (req.method === "POST") { + const runId = nanoid(); + runs.set(runId, { + listeners: [], + prev: Buffer.from([]), + statusCode: null, + }); + const execPath = join(tmpdir(), "fireactions-agent-exec-" + nanoid()); + { + const execFile = await open(execPath, "w", 0o700); + await new Promise((resolve, reject) => + req.pipe(execFile.createWriteStream()).addListener("finish", () => { + resolve(void 0); + }) + ); + } + const proc = spawn(execPath, [], {}); + proc.stdout.on("data", async (/** @type {Buffer} */ chunk) => { + writeToRun(runId, chunk); + }); + proc.stderr.on("data", async (/** @type {Buffer} */ chunk) => { + writeToRun(runId, chunk); + }); + proc.on("close", async (code) => { + const run = runs.get(runId); + if (!run) return panic(); + runs.set(runId, { ...run, statusCode: code }); + closeRun(runId); + await rm(execPath); + }); + return res.writeHead(200).end(JSON.stringify({ runId })); + } else return res.writeHead(405).end("method not allowed"); + } else if ((matches = url.pathname.match(runWebSocketRegexp))) { + const runId = matches[1]; + const run = runs.get(runId); + if (!run) { + return res.writeHead(404).end("run not found"); + } + res.writeHead(200, { + "content-type": "text/event-stream", + Connection: "keep-alive", + "Cache-Control": "no-cache", + "Access-Control-Allow-Origin": "*", + }); + const listener = (/** @type {Buffer | null} */ data) => { + if (data === null) return res.end(); + res.write( + "event: stdsmth\n" + `data: ${JSON.stringify(data.toString())}\n\n` + ); + }; + listener(run.prev); + // TODO: remove listener when request closed + runs.set(runId, { ...run, listeners: [...run.listeners, listener] }); + } else { + return res.writeHead(404).end("not found"); + } +} + +/** + * @param {import("http").IncomingMessage} req + */ +function getUrl(req) { + // prettier-ignore + const urlString = /** @type {string} */(req.url); + const url = new URL(urlString, `http://${req.headers.host}`); + return url; +} + +/** + * @returns {Promise} + */ +async function parseSecret() { + const cmdline = await readFile("/proc/cmdline", "utf-8"); + for (const opt of cmdline.split(" ")) { + const [key, value] = opt.split("="); + if (key === "fireactions.secret") { + if (value.length < 5) throw new Error("valor muy corto"); + return value; + } + } + throw new Error("no hay secreto"); +} + +/** + * @param {string} secret + * @param {import("http").IncomingMessage} req + * @param {import("http").ServerResponse & { req: import("http").IncomingMessage; }} res + */ +function auth(secret, req, res) { + const url = getUrl(req); + const auth = req.headers.authorization + ? req.headers.authorization.slice("Bearer ".length) + : url.searchParams.get("token"); + if (auth !== secret) { + res.writeHead(401).end("wrong secret"); + throw new Error("unauthorized"); + } +} + +server.listen("8080"); + +function panic() { + throw new Error("Function not implemented."); +} diff --git a/agent/main.go b/agent/main.go deleted file mode 100644 index a51939a..0000000 --- a/agent/main.go +++ /dev/null @@ -1,261 +0,0 @@ -package main - -import ( - "errors" - "io" - "io/ioutil" - "log" - "net/http" - "os" - "os/exec" - "strings" - "sync" - - "github.com/jaevor/go-nanoid" - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - gommon "github.com/labstack/gommon/log" - "golang.org/x/net/websocket" -) - -func main() { - sec, err := parseSecret() - if err != nil { - panic(err) - } - - e := echo.New() - - e.Use(middleware.Logger()) - e.Use(middleware.RecoverWithConfig(middleware.RecoverConfig{ - Skipper: middleware.DefaultSkipper, - LogLevel: gommon.ERROR, - })) - au := auth{secret: sec} - - e.GET("/hello", hello) - e.POST("/run", runHandler, au.headerMiddleware) - e.GET("/run/:id", runLogHandler, au.queryMiddleware) - - e.Logger.Fatal(e.Start(":8080")) -} - -func hello(c echo.Context) error { - return c.String(http.StatusOK, "Hello, World!") -} - -type runResp struct { - RunId string -} - -type run struct { - mutex sync.Mutex - clients []client - prev []byte - msgChan chan []byte -} - -func newRun() *run { - r := &run{ - msgChan: make(chan []byte), - } - go r.pump() - return r -} -func (r *run) newClient(ws *websocket.Conn) { - cl := client{conn: ws, msgChan: make(chan []byte)} - prev := func() []byte { - r.mutex.Lock() - defer r.mutex.Unlock() - r.clients = append(r.clients, cl) - return r.prev - }() - cl.pump(prev) -} -func (r *run) pump() { - for { - select { - case msg, ok := <-r.msgChan: - func() { - r.mutex.Lock() - defer r.mutex.Unlock() - if !ok { - for _, c := range r.clients { - close(c.msgChan) - } - return - } - r.prev = append(r.prev, msg...) - for _, c := range r.clients { - c.msgChan <- msg - } - }() - if !ok { - return - } - - } - } -} - -// Write implements io.Writer -func (r *run) Write(p []byte) (n int, err error) { - r.msgChan <- p - return len(p), nil -} - -// Close implements io.Closer -func (r *run) Close() error { - close(r.msgChan) - return nil -} - -type client struct { - conn *websocket.Conn - msgChan chan []byte -} - -func (c *client) pump(prev []byte) { - _, err := c.conn.Write(prev) - if err != nil { - close(c.msgChan) - return - } - for { - select { - case msg, ok := <-c.msgChan: - if !ok { - return - } - _, err := c.conn.Write(msg) - if err != nil { - close(c.msgChan) - return - } - } - } -} - -var runs = struct { - runs map[string]*run - sync.Mutex -}{runs: make(map[string]*run)} - -func runHandler(c echo.Context) error { - f, err := os.CreateTemp("", "fireactions-agent-*") - if err != nil { - log.Panicln(err) - } - if _, err = io.Copy(f, c.Request().Body); err != nil { - log.Panicln(err) - } - if err = f.Close(); err != nil { - log.Panicln(err) - } - - if err = os.Chmod(f.Name(), 0700); err != nil { - log.Panicln(err) - } - - nanid, err := nanoid.Standard(21) - if err != nil { - log.Panicln(err) - } - - id := nanid() - run := newRun() - func() { - runs.Lock() - defer runs.Unlock() - runs.runs[id] = run - }() - - cmd := exec.Command(f.Name()) - cmd.Stdout = run - cmd.Stderr = run - err = cmd.Start() - go func() { - defer run.Close() - err := cmd.Wait() - if err != nil { - log.Println(err) - } - }() - if err != nil { - log.Println(err) - return c.String(http.StatusInternalServerError, "error running command") - } - return c.JSON(http.StatusOK, runResp{ - RunId: id, - }) -} - -func runLogHandler(c echo.Context) error { - id := c.Param("id") - r, exists := func() (*run, bool) { - runs.Lock() - defer runs.Unlock() - r, ok := runs.runs[id] - return r, ok - }() - if !exists { - return c.String(http.StatusNotFound, "run not found") - } - websocket.Handler(func(ws *websocket.Conn) { - r.newClient(ws) - }).ServeHTTP(c.Response(), c.Request()) - return nil -} - -type auth struct { - secret string -} - -func (a auth) headerMiddleware(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - s := strings.Split(c.Request().Header.Get("Authorization"), " ") - if len(s) < 2 { - return c.String(http.StatusBadRequest, "fuck no") - } - sec := s[1] - log.Println(len(sec), len(a.secret)) - if sec == a.secret { - return next(c) - } else { - return c.String(http.StatusUnauthorized, "wrong secret") - } - } -} -func (a auth) queryMiddleware(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - sec := c.Request().URL.Query().Get("token") - if sec == a.secret { - return next(c) - } else { - return c.String(http.StatusUnauthorized, "wrong secret") - } - } -} - -func parseSecret() (string, error) { - byt, err := ioutil.ReadFile("/proc/cmdline") - if err != nil { - return "", err - } - opts := strings.Split(string(byt), " ") - for _, opt := range opts { - s := strings.Split(opt, "=") - if len(s) != 2 { - continue - } - key := s[0] - val := s[1] - if key == "fireactions.secret" { - if len(val) < 5 { - return "", errors.New("secret too short") - } - return val, nil - } - } - return "", errors.New("no secret in cmdline") -} diff --git a/agent/package.json b/agent/package.json new file mode 100644 index 0000000..e44a6cb --- /dev/null +++ b/agent/package.json @@ -0,0 +1,22 @@ +{ + "name": "agent", + "type": "module", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@tsconfig/node16": "^1.0.4", + "@types/node": "^20.2.5", + "@types/ws": "^8.5.4" + }, + "dependencies": { + "nanoid": "^4.0.2", + "ws": "^8.13.0" + } +} diff --git a/agent/pnpm-lock.yaml b/agent/pnpm-lock.yaml new file mode 100644 index 0000000..c67f9d2 --- /dev/null +++ b/agent/pnpm-lock.yaml @@ -0,0 +1,59 @@ +lockfileVersion: '6.1' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + nanoid: + specifier: ^4.0.2 + version: 4.0.2 + ws: + specifier: ^8.13.0 + version: 8.13.0 + +devDependencies: + '@tsconfig/node16': + specifier: ^1.0.4 + version: 1.0.4 + '@types/node': + specifier: ^20.2.5 + version: 20.2.5 + '@types/ws': + specifier: ^8.5.4 + version: 8.5.4 + +packages: + + /@tsconfig/node16@1.0.4: + resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} + dev: true + + /@types/node@20.2.5: + resolution: {integrity: sha512-JJulVEQXmiY9Px5axXHeYGLSjhkZEnD+MDPDGbCbIAbMslkKwmygtZFy1X6s/075Yo94sf8GuSlFfPzysQrWZQ==} + dev: true + + /@types/ws@8.5.4: + resolution: {integrity: sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==} + dependencies: + '@types/node': 20.2.5 + dev: true + + /nanoid@4.0.2: + resolution: {integrity: sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==} + engines: {node: ^14 || ^16 || >=18} + hasBin: true + dev: false + + /ws@8.13.0: + resolution: {integrity: sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: false diff --git a/agent/tsconfig.json b/agent/tsconfig.json new file mode 100644 index 0000000..7105231 --- /dev/null +++ b/agent/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@tsconfig/node16/tsconfig.json", + "exclude": ["build.js/", "build/"], + "compilerOptions": { + "allowJs": true, + "checkJs": true + } +}