diff --git a/api/package-lock.json b/api/package-lock.json index c47d8b8..8c8959d 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -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", diff --git a/api/package.json b/api/package.json index 17c57fd..67bf1d4 100644 --- a/api/package.json +++ b/api/package.json @@ -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", diff --git a/api/routes/NextNet API/Backend/Create.bru b/api/routes/NextNet API/Backend/Create.bru index 9095bc0..15f623f 100644 --- a/api/routes/NextNet API/Backend/Create.bru +++ b/api/routes/NextNet API/Backend/Create.bru @@ -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" + } } } } diff --git a/api/routes/Passyfire Base Routes/Passyfire Base Routes/Get All Scopes.bru b/api/routes/Passyfire Base Routes/Passyfire Base Routes/Get All Scopes.bru new file mode 100644 index 0000000..6ae13c6 --- /dev/null +++ b/api/routes/Passyfire Base Routes/Passyfire Base Routes/Get All Scopes.bru @@ -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 +} diff --git a/api/routes/Passyfire Base Routes/Passyfire Base Routes/Get Tunnels.bru b/api/routes/Passyfire Base Routes/Passyfire Base Routes/Get Tunnels.bru new file mode 100644 index 0000000..a1746a1 --- /dev/null +++ b/api/routes/Passyfire Base Routes/Passyfire Base Routes/Get Tunnels.bru @@ -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" + } +} diff --git a/api/routes/Passyfire Base Routes/Passyfire Base Routes/Log In.bru b/api/routes/Passyfire Base Routes/Passyfire Base Routes/Log In.bru new file mode 100644 index 0000000..0ecdb33 --- /dev/null +++ b/api/routes/Passyfire Base Routes/Passyfire Base Routes/Log In.bru @@ -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" + } +} diff --git a/api/routes/Passyfire Base Routes/Passyfire Base Routes/bruno.json b/api/routes/Passyfire Base Routes/Passyfire Base Routes/bruno.json new file mode 100644 index 0000000..0fdaa19 --- /dev/null +++ b/api/routes/Passyfire Base Routes/Passyfire Base Routes/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "Passyfire Base Routes", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/api/src/backendimpl/index.ts b/api/src/backendimpl/index.ts index 671e7f8..b248aff 100644 --- a/api/src/backendimpl/index.ts +++ b/api/src/backendimpl/index.ts @@ -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 = { - ssh: SSHBackendProvider, -}; + "ssh": SSHBackendProvider, + "passyfire": PassyFireBackendProvider +}; \ No newline at end of file diff --git a/api/src/backendimpl/passyfire-reimpl/index.ts b/api/src/backendimpl/passyfire-reimpl/index.ts new file mode 100644 index 0000000..0da49cd --- /dev/null +++ b/api/src/backendimpl/passyfire-reimpl/index.ts @@ -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 +}; + +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 { + 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 { + 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 + } + }; +} \ No newline at end of file diff --git a/api/src/backendimpl/passyfire-reimpl/routes.ts b/api/src/backendimpl/passyfire-reimpl/routes.ts new file mode 100644 index 0000000..f1cb560 --- /dev/null +++ b/api/src/backendimpl/passyfire-reimpl/routes.ts @@ -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 + })) + }); + }); +} \ No newline at end of file diff --git a/api/src/backendimpl/passyfire-reimpl/socket.ts b/api/src/backendimpl/passyfire-reimpl/socket.ts new file mode 100644 index 0000000..bd97620 --- /dev/null +++ b/api/src/backendimpl/passyfire-reimpl/socket.ts @@ -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}'`); + } + }); +} \ No newline at end of file diff --git a/api/src/index.ts b/api/src/index.ts index e56339b..e69b9f2 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -55,6 +55,7 @@ const backends: Record = {}; const fastify = Fastify({ logger: true, + trustProxy: Boolean(process.env.IS_BEHIND_PROXY) }); const routeOptions: RouteOptions = {