Merge pull request #4 from greysoh/reimpl-passyfire

Reimplements PassyFire as a possible backend
This commit is contained in:
Greyson 2024-05-05 17:02:07 -04:00 committed by GitHub
commit 5f1df9ca88
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 618 additions and 7 deletions

83
api/package-lock.json generated
View file

@ -9,6 +9,7 @@
"version": "0.1.0",
"license": "BSD-3-Clause",
"dependencies": {
"@fastify/websocket": "^10.0.1",
"@prisma/client": "^5.13.0",
"bcrypt": "^5.1.1",
"fastify": "^4.26.2",
@ -18,6 +19,7 @@
"@types/bcrypt": "^5.0.2",
"@types/node": "^20.12.7",
"@types/ssh2": "^1.15.0",
"@types/ws": "^8.5.10",
"nodemon": "^3.0.3",
"prettier": "^3.2.5",
"prisma": "^5.13.0",
@ -55,6 +57,16 @@
"fast-deep-equal": "^3.1.3"
}
},
"node_modules/@fastify/websocket": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-10.0.1.tgz",
"integrity": "sha512-8/pQIxTPRD8U94aILTeJ+2O3el/r19+Ej5z1O1mXlqplsUH7KzCjAI0sgd5DM/NoPjAi5qLFNIjgM5+9/rGSNw==",
"dependencies": {
"duplexify": "^4.1.2",
"fastify-plugin": "^4.0.0",
"ws": "^8.0.0"
}
},
"node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
@ -186,6 +198,15 @@
"undici-types": "~5.26.4"
}
},
"node_modules/@types/ws": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.10.tgz",
"integrity": "sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
@ -543,11 +564,43 @@
"node": ">=8"
}
},
"node_modules/duplexify": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz",
"integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==",
"dependencies": {
"end-of-stream": "^1.4.1",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1",
"stream-shift": "^1.0.2"
}
},
"node_modules/duplexify/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"node_modules/end-of-stream": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
"integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
@ -663,6 +716,11 @@
"toad-cache": "^3.3.0"
}
},
"node_modules/fastify-plugin": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-4.5.1.tgz",
"integrity": "sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ=="
},
"node_modules/fastq": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz",
@ -1528,6 +1586,11 @@
"nan": "^2.18.0"
}
},
"node_modules/stream-shift": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz",
"integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ=="
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@ -1703,6 +1766,26 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
},
"node_modules/ws": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.0.tgz",
"integrity": "sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow==",
"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
}
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",

View file

@ -17,12 +17,14 @@
"@types/bcrypt": "^5.0.2",
"@types/node": "^20.12.7",
"@types/ssh2": "^1.15.0",
"@types/ws": "^8.5.10",
"nodemon": "^3.0.3",
"prettier": "^3.2.5",
"prisma": "^5.13.0",
"typescript": "^5.3.3"
},
"dependencies": {
"@fastify/websocket": "^10.0.1",
"@prisma/client": "^5.13.0",
"bcrypt": "^5.1.1",
"fastify": "^4.26.2",

View file

@ -12,12 +12,17 @@ post {
body:json {
{
"token": "914abf2223f84375eed884671bfaefd7755d378af496b345f322214e75b51ed4465f11e26c944914c9b4fcc35c53250325fbc6530853ddfed8f72976d6fc5",
"name": "SSH Route",
"description": "This is a test route for SSH connectivity",
"backend": "ssh",
"token": "9d99397be36747b9e6f1858f1efded4756ea5b479fd5c47a6388041eecb44b4958858c6fe15f23a9cf5e9d67f48443c65342e3a69bfde231114df4bb2ab457",
"name": "Passyfire Reimpl",
"description": "PassyFire never dies",
"backend": "passyfire",
"connectionDetails": {
"funny": true
"ip": "127.0.0.1",
"port": 22,
"users": {
"g"
}
}
}
}

View file

@ -0,0 +1,11 @@
meta {
name: Get All Scopes
type: http
seq: 1
}
get {
url: http://127.0.0.1:8080/api/v1/static/getScopes
body: none
auth: none
}

View file

@ -0,0 +1,17 @@
meta {
name: Get Tunnels
type: http
seq: 3
}
post {
url: http://127.0.0.1:8080/api/v1/tunnels
body: json
auth: none
}
body:json {
{
"token": "641d968c3bfdf78f2df86cae106349c4c95a8dd73512ee34b296379b6cd908c87b078f1f674b43c9e3394c8b233840512d88efdecf47dc63be93276f56c"
}
}

View file

@ -0,0 +1,18 @@
meta {
name: Log In
type: http
seq: 2
}
post {
url: http://127.0.0.1:8080/api/v1/users/login
body: json
auth: none
}
body:json {
{
"username": "guest",
"password": "guest"
}
}

View file

@ -0,0 +1,9 @@
{
"version": "1",
"name": "Passyfire Base Routes",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View file

@ -1,6 +1,9 @@
import type { BackendBaseClass } from "./base.js";
import { PassyFireBackendProvider } from "./passyfire-reimpl/index.js";
import { SSHBackendProvider } from "./ssh.js";
export const backendProviders: Record<string, typeof BackendBaseClass> = {
ssh: SSHBackendProvider,
"ssh": SSHBackendProvider,
"passyfire": PassyFireBackendProvider
};

View file

@ -0,0 +1,185 @@
import fastifyWebsocket from "@fastify/websocket";
import type { FastifyInstance } from "fastify";
import Fastify from "fastify";
import type { ForwardRule, ConnectedClient, ParameterReturnedValue, BackendBaseClass } from "../base.js";
import { generateRandomData } from "../../libs/generateRandom.js";
import { requestHandler } from "./socket.js";
import { route } from "./routes.js";
type BackendProviderUser = {
username: string,
password: string
}
export type ForwardRuleExt = ForwardRule & {
protocol: "tcp" | "udp",
userConfig: Record<string, string>
};
export type ConnectedClientExt = ConnectedClient & {
connectionDetails: ForwardRuleExt;
username: string;
};
// Fight me (for better naming)
type BackendParsedProviderString = {
ip: string,
port: number,
publicPort?: number,
isProxied?: boolean,
users: BackendProviderUser[]
}
type LoggedInUser = {
username: string,
token: string
}
function parseBackendProviderString(data: string): BackendParsedProviderString {
try {
JSON.parse(data);
} catch (e) {
throw new Error("Payload body is not JSON");
}
const jsonData = JSON.parse(data);
if (typeof jsonData.ip != "string") throw new Error("IP field is not a string");
if (typeof jsonData.port != "number") throw new Error("Port is not a number");
if (typeof jsonData.publicPort != "undefined" && typeof jsonData.publicPort != "number") throw new Error("(optional field) Proxied port is not a number");
if (typeof jsonData.isProxied != "undefined" && typeof jsonData.isProxied != "boolean") throw new Error("(optional field) 'Is proxied' is not a boolean");
if (!Array.isArray(jsonData.users)) throw new Error("Users is not an array");
for (const userIndex in jsonData.users) {
const user = jsonData.users[userIndex];
if (typeof user.username != "string") throw new Error("Username is not a string, in users array");
if (typeof user.password != "string") throw new Error("Password is not a string, in users array");
}
return {
ip: jsonData.ip,
port: jsonData.port,
publicPort: jsonData.publicPort,
isProxied: jsonData.isProxied,
users: jsonData.users
}
}
export class PassyFireBackendProvider implements BackendBaseClass {
state: "stopped" | "stopping" | "started" | "starting";
clients: ConnectedClientExt[];
proxies: ForwardRuleExt[];
users: LoggedInUser[];
logs: string[];
options: BackendParsedProviderString;
fastify: FastifyInstance;
constructor(parameters: string) {
this.logs = [];
this.clients = [];
this.proxies = [];
this.state = "stopped";
this.options = parseBackendProviderString(parameters);
this.users = [];
}
async start(): Promise<boolean> {
this.state = "starting";
this.fastify = Fastify({
logger: true,
trustProxy: this.options.isProxied
});
await this.fastify.register(fastifyWebsocket);
route(this);
this.fastify.get("/", { websocket: true }, (ws, req) => requestHandler(this, ws, req));
await this.fastify.listen({
port: this.options.port,
host: this.options.ip
});
this.state = "started";
return true;
}
async stop(): Promise<boolean> {
await this.fastify.close();
this.users.splice(0, this.users.length);
this.proxies.splice(0, this.proxies.length);
this.clients.splice(0, this.clients.length);
return true;
};
addConnection(sourceIP: string, sourcePort: number, destPort: number, protocol: "tcp" | "udp"): void {
const proxy: ForwardRuleExt = {
sourceIP,
sourcePort,
destPort,
protocol,
userConfig: {}
};
for (const user of this.options.users) {
proxy.userConfig[user.username] = generateRandomData();
}
this.proxies.push(proxy);
};
removeConnection(sourceIP: string, sourcePort: number, destPort: number, protocol: "tcp" | "udp"): void {
const connectionCheck = PassyFireBackendProvider.checkParametersConnection(sourceIP, sourcePort, destPort, protocol);
if (!connectionCheck.success) throw new Error(connectionCheck.message);
const foundProxyEntry = this.proxies.find((i) => i.sourceIP == sourceIP && i.sourcePort == sourcePort && i.destPort == destPort);
if (!foundProxyEntry) return;
this.proxies.splice(this.proxies.indexOf(foundProxyEntry), 1);
return;
};
getAllConnections(): ConnectedClient[] {
if (this.clients == null) return [];
return this.clients;
};
static checkParametersConnection(sourceIP: string, sourcePort: number, destPort: number, protocol: "tcp" | "udp"): ParameterReturnedValue {
return {
success: true
}
};
static checkParametersBackendInstance(data: string): ParameterReturnedValue {
try {
parseBackendProviderString(data);
// @ts-ignore
} catch (e: Error) {
return {
success: false,
message: e.toString()
}
}
return {
success: true
}
};
}

View file

@ -0,0 +1,163 @@
import { generateRandomData } from "../../libs/generateRandom.js";
import type { PassyFireBackendProvider } from "./index.js";
export function route(instance: PassyFireBackendProvider) {
const { fastify } = instance;
const proxiedPort: number = instance.options.publicPort ?? 443;
const unsupportedSpoofedRoutes: string[] = [
"/api/v1/tunnels/add",
"/api/v1/tunnels/edit",
"/api/v1/tunnels/remove",
// TODO (greysoh): Should we implement these? We have these for internal reasons. We could expose these /shrug
"/api/v1/tunnels/start",
"/api/v1/tunnels/stop",
// Same scenario for this API.
"/api/v1/users",
"/api/v1/users/add",
"/api/v1/users/remove",
"/api/v1/users/enable",
"/api/v1/users/disable",
];
fastify.get("/api/v1/static/getScopes", () => {
return {
success: true,
data: {
users: {
add: true,
remove: true,
get: true,
getPasswords: true
},
routes: {
add: true,
remove: true,
start: true,
stop: true,
get: true,
getPasswords: true
}
}
}
});
for (const spoofedRoute of unsupportedSpoofedRoutes) {
fastify.post(spoofedRoute, (req, res) => {
if (typeof req.body != "string") return res.status(400).send({
error: "Invalid token"
});
try {
JSON.parse(req.body);
} catch (e) {
return res.status(400).send({
error: "Invalid token"
})
}
// @ts-ignore
if (!req.body.token) return res.status(400).send({
error: "Invalid token"
});
return res.status(403).send({
error: "Invalid scope(s)"
});
})
}
fastify.post("/api/v1/users/login", {
schema: {
body: {
type: "object",
required: ["username", "password"],
properties: {
username: { type: "string" },
password: { type: "string" }
}
}
}
}, (req, res) => {
// @ts-ignore
const body: {
username: string,
password: string
} = req.body;
if (!instance.options.users.find((i) => i.username == body.username && i.password == body.password)) {
return res.status(403).send({
error: "Invalid username/password."
});
};
const token = generateRandomData();
instance.users.push({
username: body.username,
token
});
return {
success: true,
data: {
token
}
}
});
fastify.post("/api/v1/tunnels", {
schema: {
body: {
type: "object",
required: ["token"],
properties: {
token: { type: "string" },
},
},
},
}, async (req, res) => {
// @ts-ignore
const body: {
token: string
} = req.body;
const userData = instance.users.find(user => user.token == body.token);
if (!userData) return res.status(403).send({
error: "Invalid token"
});
// const host = req.hostname.substring(0, req.hostname.indexOf(":"));
const unparsedPort = req.hostname.substring(req.hostname.indexOf(":") + 1);
// @ts-ignore
// parseInt(...) can take a number just fine, at least in Node.JS
const port = parseInt(unparsedPort == "" ? proxiedPort : unparsedPort);
// This protocol is so confusing. I'm sorry.
res.send({
success: true,
data: instance.proxies.map((proxy) => ({
proxyUrlSettings: {
host: "sameAs", // Makes pfC work (this is by design apparently)
port,
protocol: proxy.protocol.toUpperCase()
},
dest: `${proxy.sourceIP}:${proxy.destPort}`,
name: `${proxy.protocol.toUpperCase()} on ::${proxy.sourcePort} -> ::${proxy.destPort}`,
passwords: [
proxy.userConfig[userData.username]
],
running: true
}))
});
});
}

View file

@ -0,0 +1,114 @@
import dgram from "node:dgram";
import net from "node:net";
import type { WebSocket } from "@fastify/websocket";
import type { FastifyRequest } from "fastify";
import type { ConnectedClientExt, PassyFireBackendProvider } from "./index.js";
// This code sucks because this protocol sucks BUUUT it works, and I don't wanna reinvent
// the gosh darn wheel for (almost) no reason
function authenticateSocket(instance: PassyFireBackendProvider, ws: WebSocket, message: string, state: ConnectedClientExt): Boolean {
if (!message.startsWith("Accept: ")) {
ws.send("400 Bad Request");
return false;
}
const type = message.substring(message.indexOf(":") + 1).trim();
if (type == "IsPassedWS") {
ws.send("AcceptResponse IsPassedWS: true");
} else if (type.startsWith("Bearer")) {
const token = type.substring(type.indexOf("Bearer") + 7);
for (const proxy of instance.proxies) {
for (const username of Object.keys(proxy.userConfig)) {
const currentToken = proxy.userConfig[username];
if (token == currentToken) {
state.connectionDetails = proxy;
state.username = username;
};
};
};
if (state.connectionDetails && state.username) {
ws.send("AcceptResponse Bearer: true");
return true;
} else {
ws.send("AcceptResponse Bearer: false");
}
}
return false;
}
export function requestHandler(instance: PassyFireBackendProvider, ws: WebSocket, req: FastifyRequest) {
let state: "authentication" | "data" = "authentication";
let socket: dgram.Socket | net.Socket | undefined;
// @ts-ignore
let connectedClient: ConnectedClientExt = {};
ws.on("close", () => {
instance.clients.splice(instance.clients.indexOf(connectedClient as ConnectedClientExt), 1);
});
ws.on("message", (rawData: ArrayBuffer) => {
if (state == "authentication") {
const data = rawData.toString();
if (authenticateSocket(instance, ws, data, connectedClient)) {
ws.send("AcceptResponse Bearer: true");
connectedClient.ip = req.ip;
connectedClient.port = req.socket.remotePort ?? -1;
instance.clients.push(connectedClient);
if (connectedClient.connectionDetails.protocol == "tcp") {
socket = new net.Socket();
socket.connect(connectedClient.connectionDetails.sourcePort, connectedClient.connectionDetails.sourceIP);
socket.on("connect", () => {
state = "data";
ws.send("InitProxy: Attempting to connect");
ws.send("InitProxy: Connected");
});
socket.on("data", (data) => {
ws.send(data);
});
} else if (connectedClient.connectionDetails.protocol == "udp") {
socket = dgram.createSocket("udp4");
state = "data";
ws.send("InitProxy: Attempting to connect");
ws.send("InitProxy: Connected");
socket.on("message", (data, rinfo) => {
if (rinfo.address != connectedClient.connectionDetails.sourceIP || rinfo.port != connectedClient.connectionDetails.sourcePort) return;
ws.send(data);
});
}
}
} else if (state == "data") {
if (socket instanceof dgram.Socket) {
const array = new Uint8Array(rawData);
socket.send(array, connectedClient.connectionDetails.sourcePort, connectedClient.connectionDetails.sourceIP, (err) => {
if (err) throw err;
});
} else if (socket instanceof net.Socket) {
const array = new Uint8Array(rawData);
socket.write(array);
}
} else {
throw new Error(`Whooops, our WebSocket reached an unsupported state: '${state}'`);
}
});
}

View file

@ -55,6 +55,7 @@ const backends: Record<number, BackendBaseClass> = {};
const fastify = Fastify({
logger: true,
trustProxy: Boolean(process.env.IS_BEHIND_PROXY)
});
const routeOptions: RouteOptions = {