js agent
This commit is contained in:
parent
e3995c1926
commit
a46881fcbe
5 changed files with 273 additions and 261 deletions
184
agent/index.js
Normal file
184
agent/index.js
Normal file
|
@ -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<string, {listeners: ((data: Buffer | null) => 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<import("http").IncomingMessage> & { 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<string>}
|
||||||
|
*/
|
||||||
|
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<import("http").IncomingMessage> & { 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.");
|
||||||
|
}
|
261
agent/main.go
261
agent/main.go
|
@ -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")
|
|
||||||
}
|
|
22
agent/package.json
Normal file
22
agent/package.json
Normal file
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
59
agent/pnpm-lock.yaml
Normal file
59
agent/pnpm-lock.yaml
Normal file
|
@ -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
|
8
agent/tsconfig.json
Normal file
8
agent/tsconfig.json
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"extends": "@tsconfig/node16/tsconfig.json",
|
||||||
|
"exclude": ["build.js/", "build/"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue